Compare commits
27 Commits
ec3dbbaf3a
...
ci
| Author | SHA1 | Date | |
|---|---|---|---|
| f7dd929bdd | |||
| 30aba3c871 | |||
| cb8400eea3 | |||
| 63875a6f59 | |||
| b88208ba3d | |||
| 5ceadd09dd | |||
| 7387f33678 | |||
| f4c704740a | |||
| 0c6cdcf1f5 | |||
| 6bfa971b0a | |||
| 809fa354c4 | |||
| e0efd9dc41 | |||
| 68756014be | |||
| ff22127baa | |||
| 79f8adaf1a | |||
| 4413fab9ad | |||
| e89acf585f | |||
| 2a0bfd0e10 | |||
| ac4b86fd03 | |||
| f05c4bbcc7 | |||
| d2ac081d5e | |||
| 39af776d00 | |||
| 15130938ce | |||
| 05ac24254c | |||
| 72a245610a | |||
| 7040909625 | |||
| ec9426b59c |
82
.drone.yml
Normal file
82
.drone.yml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: kubernetes
|
||||||
|
name: build-clis
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
branch:
|
||||||
|
- main
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: build
|
||||||
|
image: golang:1.17
|
||||||
|
commands:
|
||||||
|
- make build-cross-clis
|
||||||
|
- name: upload
|
||||||
|
image: plugins/s3
|
||||||
|
settings:
|
||||||
|
bucket: covergen
|
||||||
|
source: dist/*
|
||||||
|
target: /dist/
|
||||||
|
strip_prefix: dist/
|
||||||
|
|
||||||
|
path_style: true
|
||||||
|
endpoint: https://s3.blacknova.io
|
||||||
|
access_key:
|
||||||
|
from_secret: minio_access_key_id
|
||||||
|
secret_key:
|
||||||
|
from_secret: minio_secret_access_key
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: kubernetes
|
||||||
|
name: build-wasm
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
branch:
|
||||||
|
- main
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: wasm
|
||||||
|
temp: {}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: build wasm
|
||||||
|
image: golang:1.17
|
||||||
|
environment:
|
||||||
|
GOOS: js
|
||||||
|
GOARCH: wasm
|
||||||
|
volumes:
|
||||||
|
- name: wasm
|
||||||
|
path: /wasm
|
||||||
|
commands:
|
||||||
|
- make wasm
|
||||||
|
- "cp covergen.wasm /wasm/covergen.wasm"
|
||||||
|
# Grab the wasm shim from the docker image, otherwise there is a mismatch
|
||||||
|
- "cp /usr/local/go/misc/wasm/wasm_exec.js /wasm/wasm_exec.js"
|
||||||
|
|
||||||
|
- name: build frontend
|
||||||
|
image: node:16
|
||||||
|
volumes:
|
||||||
|
- name: wasm
|
||||||
|
path: /wasm
|
||||||
|
commands:
|
||||||
|
- cd frontend
|
||||||
|
- npm install
|
||||||
|
- "cp /wasm/* public/"
|
||||||
|
- npm run build
|
||||||
|
- name: upload
|
||||||
|
image: plugins/s3
|
||||||
|
settings:
|
||||||
|
bucket: covergen
|
||||||
|
source: frontend/dist/**/*
|
||||||
|
target: /
|
||||||
|
strip_prefix: frontend/dist/
|
||||||
|
|
||||||
|
path_style: true
|
||||||
|
endpoint: https://s3.blacknova.io
|
||||||
|
access_key:
|
||||||
|
from_secret: minio_access_key_id
|
||||||
|
secret_key:
|
||||||
|
from_secret: minio_secret_access_key
|
||||||
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,5 @@
|
|||||||
covergen
|
covergen
|
||||||
!covergen/
|
!covergen/
|
||||||
*.pdf
|
*.pdf
|
||||||
|
*.wasm
|
||||||
|
dist/
|
||||||
52
.gitlab-ci.yml
Normal file
52
.gitlab-ci.yml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
# cache using branch name
|
||||||
|
# https://gitlab.com/help/ci/caching/index.md
|
||||||
|
cache:
|
||||||
|
key: ${CI_COMMIT_REF_SLUG}
|
||||||
|
paths:
|
||||||
|
- node_modules
|
||||||
|
|
||||||
|
build:blobs:
|
||||||
|
image: golang:1.17
|
||||||
|
stage: build
|
||||||
|
script:
|
||||||
|
- mkdir /blobs
|
||||||
|
- make wasm
|
||||||
|
- make build-cross-clis
|
||||||
|
- mv covergen.wasm dist /blobs
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- /blobs
|
||||||
|
expire_in: 30 days
|
||||||
|
|
||||||
|
build:frontend:
|
||||||
|
image: node:16
|
||||||
|
stage: build
|
||||||
|
needs:
|
||||||
|
- build:blobs
|
||||||
|
before_script:
|
||||||
|
- corepack npm install --immutable
|
||||||
|
script:
|
||||||
|
- cd frontend
|
||||||
|
- mv /blobs/* public
|
||||||
|
- corepack npm run build
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- dist/
|
||||||
|
expire_in: 30 days
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
stage: deploy
|
||||||
|
image: alpine
|
||||||
|
needs:
|
||||||
|
- build:frontend
|
||||||
|
before_script:
|
||||||
|
- apk add lftp
|
||||||
|
script:
|
||||||
|
- lftp "$DEPLOY_USER_PASS@vps0.miwebb.com:/" -e "mirror -R dist/ .; quit"
|
||||||
|
environment:
|
||||||
|
name: live
|
||||||
|
url: https://covergen.miwebb.dev
|
||||||
30
Makefile
30
Makefile
@@ -1,3 +1,29 @@
|
|||||||
wasm:
|
.PHONY: help
|
||||||
GOOS=js GOARCH=wasm go build -o assets/covergen.wasm ./cmd/wasm/main.go
|
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
|
.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: ## 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-frontend
|
||||||
|
wasm-frontend: wasm
|
||||||
|
cp covergen.wasm frontend/public/covergen.wasm
|
||||||
|
|
||||||
|
.PHONY: clis-frontend
|
||||||
|
clis-frontend: build-cross-clis
|
||||||
|
cp -r dist/ frontend/public/
|
||||||
|
|
||||||
|
.PHONY: prepare-frontend
|
||||||
|
prepare-frontend: wasm-frontend clis-frontend ### Prepares all main-repo requirements for the frontend
|
||||||
47
README.md
Normal file
47
README.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Covergen
|
||||||
|
|
||||||
|
Just generates some magic PDF invoice covers using straight up dark magic.
|
||||||
|
|
||||||
|
## Building it
|
||||||
|
|
||||||
|
```shell
|
||||||
|
# Builds the CLI
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Builds the WASM blob
|
||||||
|
make wasm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom font notes
|
||||||
|
|
||||||
|
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.
|
||||||
Binary file not shown.
@@ -1,53 +0,0 @@
|
|||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<script src="wasm_exec.js"></script>
|
|
||||||
<script>
|
|
||||||
const go = new Go();
|
|
||||||
WebAssembly.instantiateStreaming(fetch("covergen.wasm"), go.importObject).then((result) => {
|
|
||||||
go.run(result.instance);
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeCover(args) {
|
|
||||||
const result = window.covergen(args);
|
|
||||||
if (result.error) {
|
|
||||||
throw result.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new File([result], 'cover.pdf', {type: 'application/pdf'});
|
|
||||||
}
|
|
||||||
|
|
||||||
function letsfuckinggo() {
|
|
||||||
document.getElementById('cover').src = window.URL.createObjectURL(makeCover({
|
|
||||||
customer: document.getElementById('customer').value,
|
|
||||||
number: document.getElementById('number').value,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
html, body, #cover {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div>
|
|
||||||
<label>
|
|
||||||
Customer:
|
|
||||||
<textarea id="customer"></textarea>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>
|
|
||||||
Number:
|
|
||||||
<input id="number" type="text">
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button onclick="letsfuckinggo()">Fuck it!</button>
|
|
||||||
</div>
|
|
||||||
<iframe id="cover"></iframe>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"covergen/pkg/covergen"
|
"covergen/pkg/covergen"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// List of colors used for picking a (random) color in the cli
|
||||||
var colors = []string{"red", "blue", "yellow", "green"}
|
var colors = []string{"red", "blue", "yellow", "green"}
|
||||||
var colorMap = map[string]covergen.Color{
|
var colorMap = map[string]covergen.Color{
|
||||||
"red": {255, 85, 0},
|
"red": {255, 85, 0},
|
||||||
@@ -26,6 +27,7 @@ var customer = pflag.StringP("customer", "c", "", "Customer name for cover")
|
|||||||
var numberPrefix = pflag.String("number-prefix", "offerte", "Prefix to use for number")
|
var numberPrefix = pflag.String("number-prefix", "offerte", "Prefix to use for number")
|
||||||
var color = pflag.String("color", "random", "Selects a color to use for the grid highlight. Valid choices: ['random', 'red', 'blue', 'yellow', 'green']")
|
var color = pflag.String("color", "random", "Selects a color to use for the grid highlight. Valid choices: ['random', 'red', 'blue', 'yellow', 'green']")
|
||||||
var output = pflag.StringP("output", "o", "cover.pdf", "File to output to")
|
var output = pflag.StringP("output", "o", "cover.pdf", "File to output to")
|
||||||
|
var output2 = pflag.StringP("output-back", "b", "", "Output front and back separately")
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Logger()
|
log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Logger()
|
||||||
@@ -68,14 +70,18 @@ func main() {
|
|||||||
chosenColor = colorMap[*color]
|
chosenColor = colorMap[*color]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Newlines in CLI arguments are given escaped. Unescape them to proper newlines
|
||||||
*customer = strings.ReplaceAll(*customer, "\\n", "\n")
|
*customer = strings.ReplaceAll(*customer, "\\n", "\n")
|
||||||
|
settings := covergen.CoverSettings{
|
||||||
pdf, err := covergen.GenerateInvoice(covergen.CoverSettings{
|
|
||||||
Number: *number,
|
Number: *number,
|
||||||
NumberPrefix: *numberPrefix,
|
NumberPrefix: *numberPrefix,
|
||||||
CustomerName: *customer,
|
CustomerName: *customer,
|
||||||
HLColor: chosenColor,
|
HLColor: chosenColor,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// If output2 (back page) is not set, generate a combined PDF
|
||||||
|
if *output2 == "" {
|
||||||
|
pdf, err := covergen.GenerateInvoice(settings)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("Failed to render invoice")
|
log.Fatal().Err(err).Msg("Failed to render invoice")
|
||||||
@@ -84,4 +90,23 @@ func main() {
|
|||||||
if err := pdf.OutputFileAndClose(*output); err != nil {
|
if err := pdf.OutputFileAndClose(*output); err != nil {
|
||||||
log.Fatal().Err(err).Msg("Failed to write invoice to disk")
|
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")
|
||||||
|
}
|
||||||
|
if err := front.OutputFileAndClose(*output); err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("Failed to write front to disk")
|
||||||
|
}
|
||||||
|
|
||||||
|
back, err := covergen.GenerateBackCover(settings)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("Failed to render back")
|
||||||
|
}
|
||||||
|
if err := back.OutputFileAndClose(*output2); err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("Failed to write back to disk")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
err := http.ListenAndServe(":9090", http.FileServer(http.Dir("assets")))
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("Failed to start server", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,17 +2,17 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"syscall/js"
|
"syscall/js"
|
||||||
|
|
||||||
|
"github.com/go-pdf/fpdf"
|
||||||
"github.com/thegrumpylion/jsref"
|
"github.com/thegrumpylion/jsref"
|
||||||
|
|
||||||
"covergen/pkg/covergen"
|
"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 jsmap = map[string]interface{}
|
||||||
|
|
||||||
type covergenArgs struct {
|
type covergenArgs struct {
|
||||||
@@ -22,6 +22,8 @@ type covergenArgs struct {
|
|||||||
HLColor string `jsref:"hlColor"`
|
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) {
|
func parseHexColor(s string) (c covergen.Color, err error) {
|
||||||
switch len(s) {
|
switch len(s) {
|
||||||
case 7:
|
case 7:
|
||||||
@@ -34,11 +36,12 @@ func parseHexColor(s string) (c covergen.Color, err error) {
|
|||||||
c.B *= 17
|
c.B *= 17
|
||||||
default:
|
default:
|
||||||
err = fmt.Errorf("invalid length, must be 7 or 4")
|
err = fmt.Errorf("invalid length, must be 7 or 4")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
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) {
|
func settingsFromValue(arg js.Value) (*covergen.CoverSettings, error) {
|
||||||
settings := covergenArgs{
|
settings := covergenArgs{
|
||||||
NumberPrefix: "offerte",
|
NumberPrefix: "offerte",
|
||||||
@@ -72,10 +75,24 @@ func settingsFromValue(arg js.Value) (*covergen.CoverSettings, error) {
|
|||||||
}, nil
|
}, 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 {
|
||||||
|
return js.Null(), fmt.Errorf("failed to write pdf: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := buf.Bytes()
|
||||||
|
ta := js.Global().Get("Uint8Array").New(len(s))
|
||||||
|
js.CopyBytesToJS(ta, s)
|
||||||
|
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{} {
|
func generateCover(this js.Value, args []js.Value) interface{} {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
fmt.Println("recovered", r)
|
fmt.Printf("recovered from critical error: %+v", r)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -98,21 +115,61 @@ func generateCover(this js.Value, args []js.Value) interface{} {
|
|||||||
return jsmap{"error": err.Error()}
|
return jsmap{"error": err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
jsBytes, err := pdfToJs(pdf)
|
||||||
if err = pdf.Output(&buf); err != nil {
|
if err != nil {
|
||||||
|
return jsmap{"error": err.Error()}
|
||||||
|
}
|
||||||
|
return jsBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSplitCover(this js.Value, args []js.Value) interface{} {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
fmt.Printf("recovered from critical error: %+v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if len(args) != 1 {
|
||||||
|
return jsmap{"error": "missing argument"}
|
||||||
|
}
|
||||||
|
|
||||||
|
arg := args[0]
|
||||||
|
if arg.Type() != js.TypeObject {
|
||||||
|
return jsmap{"error": "expected object"}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := settingsFromValue(arg)
|
||||||
|
if err != nil {
|
||||||
return jsmap{"error": err.Error()}
|
return jsmap{"error": err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
s := buf.Bytes()
|
front, err := covergen.GenerateFrontCover(*settings)
|
||||||
sum := sha256.Sum256(s)
|
if err != nil {
|
||||||
fmt.Println("shasum", hex.EncodeToString(sum[:]))
|
return jsmap{"error": fmt.Sprintf("failed to render front: %s", err.Error())}
|
||||||
|
}
|
||||||
|
|
||||||
ta := js.Global().Get("Uint8Array").New(len(s))
|
back, err := covergen.GenerateBackCover(*settings)
|
||||||
js.CopyBytesToJS(ta, s)
|
if err != nil {
|
||||||
return ta
|
return jsmap{"error": fmt.Sprintf("failed to render back: %s", err.Error())}
|
||||||
|
}
|
||||||
|
|
||||||
|
frontJSBytes, err := pdfToJs(front)
|
||||||
|
if err != nil {
|
||||||
|
return jsmap{"error": fmt.Sprintf("failed to render front: %s", err.Error())}
|
||||||
|
}
|
||||||
|
backJSBytes, err := pdfToJs(back)
|
||||||
|
if err != nil {
|
||||||
|
return jsmap{"error": fmt.Sprintf("failed to render back: %s", err.Error())}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsmap{
|
||||||
|
"front": frontJSBytes,
|
||||||
|
"back": backJSBytes,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
js.Global().Set("covergen", js.FuncOf(generateCover))
|
js.Global().Set("generateCover", js.FuncOf(generateCover))
|
||||||
|
js.Global().Set("generateSplitCover", js.FuncOf(generateSplitCover))
|
||||||
<-make(chan bool)
|
<-make(chan bool)
|
||||||
}
|
}
|
||||||
|
|||||||
12
frontend/.eslintrc
Normal file
12
frontend/.eslintrc
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"plugin:vue/vue3-recommended",
|
||||||
|
"plugin:prettier-vue/recommended",
|
||||||
|
"@vue/typescript/recommended",
|
||||||
|
"prettier"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/explicit-module-boundary-types": [0],
|
||||||
|
"vue/multi-word-component-names": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
8
frontend/.gitignore
vendored
Normal file
8
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
public/*.wasm
|
||||||
|
public/dist
|
||||||
3
frontend/.prettierrc
Normal file
3
frontend/.prettierrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true
|
||||||
|
}
|
||||||
30
frontend/README.md
Normal file
30
frontend/README.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Covergen Frontend
|
||||||
|
|
||||||
|
## Quick startup
|
||||||
|
|
||||||
|
For the frontend to function, the `covergen` wasm blob needs to be made available.
|
||||||
|
This is not present in-tree but is easily prepared by running `make prepare-frontend` in the parent directory
|
||||||
|
(i.e. the repo root).
|
||||||
|
This will also do prepare the cross-cli's, but those are optional (you can run `make wasm-frontend` to just do the wasm).
|
||||||
|
|
||||||
|
After this the usual `vite` / `vue3` commands apply:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
To prepare a proper final build:
|
||||||
|
```shell
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## A note on `wasm_exec.js`
|
||||||
|
|
||||||
|
Go WASM requires a 'helper' library, `wasm_exec.js` to help with the bridging memory from Go <-> JS.
|
||||||
|
This file is **different for each Go (1.XX) version**. The version bundled in-tree is for Go 1.16.
|
||||||
|
If you run a different version, you might need to copy it from your local golang dist folder,
|
||||||
|
usually `/usr/local/go/misc/wasm/wasm_exec.js`.
|
||||||
|
|
||||||
|
If your distribution doesn't provide this file (e.g. Fedora) you can just copy it from the go main repo by going to
|
||||||
|
[https://github.com/golang/go/blob/master/misc/wasm/wasm_exec.js](https://github.com/golang/go/blob/master/misc/wasm/wasm_exec.js)
|
||||||
|
and using the GitHub branch selector to select the `release-branch.go1.XX` that matches your version of go (run `go version` to find out).
|
||||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
|
||||||
|
<title>MiWebb | Covergen</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-200">
|
||||||
|
<div id="app"></div>
|
||||||
|
<script src="/wasm_exec.js"></script>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4378
frontend/package-lock.json
generated
Normal file
4378
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
frontend/package.json
Normal file
37
frontend/package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "npm run lint:ts && npm run lint:style",
|
||||||
|
"lint:ts": "vue-tsc --noEmit",
|
||||||
|
"lint:style": "eslint --ext .ts,.vue --ignore-path .gitignore .",
|
||||||
|
"lint:fix": "npm run lint:style --fix"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/forms": "^0.5.0",
|
||||||
|
"pinia": "^2.0.13",
|
||||||
|
"tailwindcss": "^3.0.24",
|
||||||
|
"vue": "^3.2.33"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/golang-wasm-exec": "^1.15.0",
|
||||||
|
"@types/node": "^17.0.25",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.20.0",
|
||||||
|
"@typescript-eslint/parser": "^5.20.0",
|
||||||
|
"@vitejs/plugin-vue": "^2.3.1",
|
||||||
|
"@vue/eslint-config-typescript": "^10.0.0",
|
||||||
|
"autoprefixer": "^10.4.4",
|
||||||
|
"eslint": "^8.13.0",
|
||||||
|
"eslint-config-prettier": "^8.5.0",
|
||||||
|
"eslint-plugin-prettier-vue": "^3.1.0",
|
||||||
|
"eslint-plugin-vue": "^8.6.0",
|
||||||
|
"postcss": "^8.4.12",
|
||||||
|
"prettier": "^2.6.2",
|
||||||
|
"typescript": "^4.6.3",
|
||||||
|
"vite": "^2.9.5",
|
||||||
|
"vue-tsc": "^0.34.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
3
frontend/public/favicon.svg
Normal file
3
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xml:space="preserve" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||||
|
<path d="m86.915 15.448-28.008 100.83-1.937 6.98h15.472l22.56-80.772 23.683 85.512-23.683 85.51-22.613-80.953H56.935l1.987 7.16 27.992 100.83H102.6l25.394-92.088 25.04 90.807.354 1.282h15.683l28.008-100.83 1.988-7.161h-15.47l-22.614 80.953-23.681-85.51 23.68-85.512 22.563 80.773h15.454l-1.937-6.98-27.992-100.83h-15.683l-25.394 92.088L102.95 16.73l-.353-1.282H86.913z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 464 B |
18
frontend/src/App.vue
Normal file
18
frontend/src/App.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Nav from './components/Nav.vue';
|
||||||
|
import Settings from './components/Settings.vue';
|
||||||
|
import Renderer from './components/Renderer.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Nav />
|
||||||
|
<div class="flex grow space-x-8">
|
||||||
|
<div class="w-80">
|
||||||
|
<Settings />
|
||||||
|
</div>
|
||||||
|
<Renderer />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
34
frontend/src/components/Nav.vue
Normal file
34
frontend/src/components/Nav.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Logo from '@/icons/Logo.vue';
|
||||||
|
|
||||||
|
const downloads = [
|
||||||
|
{ name: 'Linux', link: './dist/covergen.linux-amd64' },
|
||||||
|
{ name: 'Darwin', link: './dist/covergen.darwin-amd64' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full mx-auto px-8">
|
||||||
|
<div class="relative flex items-center justify-between h-16">
|
||||||
|
<div class="flex-1 flex items-stretch justify-between">
|
||||||
|
<div class="flex-shrink-0 flex items-center">
|
||||||
|
<Logo class="h-12" />
|
||||||
|
<h1>CoverGen</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-6">
|
||||||
|
<div class="flex space-x-4 items-center text-sm font-medium h-full">
|
||||||
|
<span class="text-base"> Download me: </span>
|
||||||
|
<a
|
||||||
|
v-for="dl in downloads"
|
||||||
|
:key="dl.link"
|
||||||
|
:href="dl.link"
|
||||||
|
class="text-gray-800 hover:bg-gray-300 px-3 py-2 rounded-md"
|
||||||
|
>{{ dl.name }}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
26
frontend/src/components/Renderer.vue
Normal file
26
frontend/src/components/Renderer.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useCover } from '@/stores/cover';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
|
const store = useCover();
|
||||||
|
const { frontUri, backUri } = storeToRefs(store);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-row grow">
|
||||||
|
<div class="grow p-4">
|
||||||
|
<h2 class="text-lg font-medium pb-4">Front</h2>
|
||||||
|
<iframe
|
||||||
|
:src="frontUri"
|
||||||
|
class="w-full aspect-A4 border border-slate-500 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grow p-4">
|
||||||
|
<h2 class="text-lg font-medium pb-4">Back</h2>
|
||||||
|
<iframe
|
||||||
|
:src="backUri"
|
||||||
|
class="w-full aspect-A4 border border-slate-500 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
70
frontend/src/components/Settings.vue
Normal file
70
frontend/src/components/Settings.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Input from '@/components/form/Input.vue';
|
||||||
|
import TextArea from '@/components/form/TextArea.vue';
|
||||||
|
import Color from '@/components/form/Color.vue';
|
||||||
|
import SadFace from '@/icons/SadFace.vue';
|
||||||
|
import { randomLabel } from '@/lib/randomlabel';
|
||||||
|
import { useCover } from '@/stores/cover';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const store = useCover();
|
||||||
|
const { customer, prefix, color, number } = storeToRefs(store);
|
||||||
|
|
||||||
|
const possibleLabels = [
|
||||||
|
'Hit it!',
|
||||||
|
'Let it rain!',
|
||||||
|
'💰💰💰',
|
||||||
|
'🤑🤑🤑',
|
||||||
|
'Make Rutte Proud',
|
||||||
|
'Kaching!',
|
||||||
|
'Rosebud',
|
||||||
|
'You show that customer',
|
||||||
|
'Do it for Berend',
|
||||||
|
];
|
||||||
|
const label = randomLabel(possibleLabels);
|
||||||
|
|
||||||
|
const renderError = ref<string | null>(null);
|
||||||
|
|
||||||
|
function doRender() {
|
||||||
|
try {
|
||||||
|
renderError.value = null;
|
||||||
|
store.render();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
renderError.value = e.message;
|
||||||
|
} else if (typeof e === 'string') {
|
||||||
|
renderError.value = e;
|
||||||
|
} else {
|
||||||
|
renderError.value = 'An unknown error occurred';
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-4 flex flex-col gap-4">
|
||||||
|
<h2 class="text-lg font-medium">Settings</h2>
|
||||||
|
<TextArea v-model="customer" label="Customer" />
|
||||||
|
<Input v-model="prefix" label="Prefix" />
|
||||||
|
<Input v-model="number" label="Number" />
|
||||||
|
<Color v-model="color" label="Highlight Color" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="renderError !== null"
|
||||||
|
class="flex bg-red-100 rounded-lg p-4 mb-4 text-sm text-red-700 items-center gap-2"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<SadFace />
|
||||||
|
<div><span class="font-medium">Error:</span> {{ renderError }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 bg-blue-500 rounded-lg hover:bg-blue-600 text-white border-2 active:border-blue-500 focus:outline focus:outline-2 focus:outline-blue-500"
|
||||||
|
@click="label.update() && doRender()"
|
||||||
|
>
|
||||||
|
{{ label.label.value }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
30
frontend/src/components/form/Color.vue
Normal file
30
frontend/src/components/form/Color.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
label: string;
|
||||||
|
modelValue: string;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:modelValue', value: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const localValue = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit('update:modelValue', value),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">{{ label }}</label>
|
||||||
|
<div class="mt-1 relative rounded-md shadow-sm">
|
||||||
|
<input
|
||||||
|
v-model="localValue"
|
||||||
|
type="color"
|
||||||
|
class="block w-full py-2 px-4 h-10 bg-white border border-gray-300 hover:border-2 hover:border-blue-500 hover:cursor-pointer focus:border-blue-500 rounded-md"
|
||||||
|
v-bind="$attrs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
30
frontend/src/components/form/Input.vue
Normal file
30
frontend/src/components/form/Input.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
label: string;
|
||||||
|
modelValue: string;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:modelValue', value: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const localValue = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit('update:modelValue', value),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">{{ label }}</label>
|
||||||
|
<div class="mt-1 relative rounded-md shadow-sm">
|
||||||
|
<input
|
||||||
|
v-model="localValue"
|
||||||
|
type="text"
|
||||||
|
class="focus:ring-blue-500 focus:border-blue-500 block w-full border-gray-300 rounded-md"
|
||||||
|
v-bind="$attrs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
29
frontend/src/components/form/TextArea.vue
Normal file
29
frontend/src/components/form/TextArea.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
label: string;
|
||||||
|
modelValue: string;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:modelValue', value: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const localValue = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit('update:modelValue', value),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">{{ label }}</label>
|
||||||
|
<div class="mt-1 relative rounded-md shadow-sm">
|
||||||
|
<textarea
|
||||||
|
v-model="localValue"
|
||||||
|
class="focus:ring-blue-500 focus:border-blue-500 block w-full border-gray-300 rounded-md"
|
||||||
|
v-bind="$attrs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
8
frontend/src/env.d.ts
vendored
Normal file
8
frontend/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import { DefineComponent } from 'vue';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
|
||||||
|
const component: DefineComponent<{}, {}, any>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
48
frontend/src/icons/Logo.vue
Normal file
48
frontend/src/icons/Logo.vue
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 400 180"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M144.452 125.98c1.289-10.15 10.953-18.66 21.799-18.66 11.49 0 21.477 7.689 23.087 18.66Zm51.543 3.485c0-15.89-12.886-28.295-29.638-28.295-16.32 0-28.992 13.121-28.992 28.397 0 15.377 13.207 27.782 29.422 27.782 11.598 0 22.121-6.46 26.846-16.506h-7.516c-4.081 6.766-11.383 10.457-19.544 10.457-11.598 0-21.477-8.713-22.121-19.785h51.543z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M145.748 124.918h42.28c-2.03-9.655-10.986-16.541-21.774-16.541-9.757 0-18.698 7.317-20.506 16.54m44.868 2.118h-47.412l.151-1.187c1.37-10.8 11.644-19.588 22.9-19.588 12.348 0 22.52 8.23 24.185 19.57zm-24.254-24.81c-15.376 0-27.885 12.265-27.885 27.34 0 14.735 12.702 26.723 28.314 26.723 10.57 0 20.23-5.595 25.073-14.388h-5.112c-4.265 6.654-11.581 10.458-20.176 10.458-5.906 0-11.562-2.14-15.924-6.027-4.383-3.904-6.977-9.146-7.303-14.76l-.066-1.118h51.607v-.991c0-7.415-2.917-14.29-8.213-19.36-5.307-5.079-12.522-7.877-20.315-7.877m.43 56.18c-16.836 0-30.533-12.938-30.533-28.84 0-16.243 13.505-29.457 30.103-29.457 17.24 0 30.747 12.893 30.747 29.354v3.108h-51.447c.552 4.643 2.834 8.944 6.503 12.213 3.95 3.519 9.069 5.456 14.411 5.456 8.005 0 14.779-3.618 18.583-9.927l.32-.53h9.871l-.7 1.493c-4.895 10.406-15.83 17.13-27.859 17.13M240.365 151.13c-12.705 0-23.042-9.867-23.042-21.997 0-12.13 10.337-21.999 23.042-21.999 12.705 0 23.042 9.869 23.042 21.999 0 12.13-10.337 21.998-23.042 21.998m0-49.996c-9.34 0-17.668 4.195-23.042 10.71v-29.13h-6.284v47.944h.045c.833 14.731 13.648 26.471 29.28 26.471 16.171 0 29.327-12.56 29.327-27.997 0-15.438-13.156-27.998-29.326-27.998"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M240.365 108.193c-12.094 0-21.933 9.393-21.933 20.939s9.839 20.94 21.933 20.94 21.932-9.394 21.932-20.94c0-11.546-9.838-20.939-21.932-20.939m0 43.997c-13.318 0-24.151-10.344-24.151-23.058 0-12.713 10.833-23.056 24.15-23.056 13.318 0 24.152 10.343 24.152 23.056 0 12.714-10.834 23.058-24.151 23.058m-28.217-22.36.043.771c.808 14.283 13.184 25.47 28.174 25.47 15.558 0 28.217-12.084 28.217-26.939 0-14.854-12.659-26.94-28.217-26.94-8.687 0-16.767 3.757-22.171 10.307l-1.98 2.401V83.772h-4.066zm28.217 28.359c-15.817 0-28.934-11.549-30.314-26.472h-.121V81.655h8.502v27.358c5.732-5.713 13.571-8.938 21.933-8.938 16.782 0 30.436 13.035 30.436 29.057s-13.654 29.057-30.436 29.057M61.529 21.606l-17.78 61.11-1.23 4.231h9.822l14.322-48.953L81.697 89.82l-15.034 51.824L52.308 92.58h-9.81l1.261 4.34 17.77 61.11h9.957l16.12-55.811 15.895 55.034.226.778h9.955l17.78-61.111 1.262-4.34h-9.82l-14.356 49.062L93.515 89.82l15.033-51.825 14.323 48.953h9.81l-1.23-4.23-17.77-61.111h-9.955l-16.12 55.811-15.897-55.034-.224-.777h-9.957zM169.784 86.007H163.5V40.574h6.284z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M164.608 84.948h4.067V41.63h-4.067zm6.284 2.118h-8.502V39.514h8.502z"
|
||||||
|
/>
|
||||||
|
<path d="M169.784 46.568h-16.286v-6h16.286z" />
|
||||||
|
<path
|
||||||
|
d="M154.604 45.517h14.067v-3.882h-14.067Zm16.286 2.117h-18.505v-8.117h18.505zM169.784 28.654H163.5v-6h6.284z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M164.608 27.595h4.067v-3.883h-4.067zm6.284 2.117h-8.502v-8.117h8.502zM356.393 157.132h-6.285v-6h6.285z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M351.218 156.073h4.066v-3.883h-4.066zm6.285 2.117H349v-8.117h8.503zM217.325 155.916h-6.284v-29.934h6.284z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M212.15 154.857h4.066V127.04h-4.066zm6.284 2.118h-8.502v-32.053h8.502z"
|
||||||
|
/>
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
d="M313.766 151.13c-12.705 0-23.041-9.867-23.041-21.997 0-12.13 10.336-21.999 23.041-21.999 12.706 0 23.042 9.869 23.042 21.999 0 12.13-10.336 21.998-23.042 21.998m0-49.996c-9.34 0-17.668 4.195-23.041 10.71v-29.13h-6.285v47.944h.045c.834 14.731 13.648 26.471 29.281 26.471 16.17 0 29.326-12.56 29.326-27.997 0-15.438-13.155-27.998-29.326-27.998"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
d="M313.766 108.193c-12.094 0-21.932 9.393-21.932 20.939s9.838 20.94 21.932 20.94 21.934-9.394 21.934-20.94c0-11.546-9.84-20.939-21.934-20.939m0 43.997c-13.316 0-24.15-10.344-24.15-23.058 0-12.713 10.834-23.056 24.15-23.056 13.318 0 24.152 10.343 24.152 23.056 0 12.714-10.834 23.058-24.152 23.058m-28.216-22.36.042.771c.808 14.283 13.184 25.47 28.174 25.47 15.56 0 28.218-12.084 28.218-26.939 0-14.854-12.658-26.94-28.218-26.94-8.686 0-16.767 3.757-22.17 10.307l-1.98 2.401V83.772h-4.066zm28.216 28.359c-15.817 0-28.934-11.549-30.314-26.472h-.12V81.655h8.502v27.358c5.731-5.713 13.57-8.938 21.932-8.938 16.782 0 30.436 13.035 30.436 29.057s-13.654 29.057-30.436 29.057"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<path d="M290.716 155.918h-6.285v-29.935h6.285z" />
|
||||||
|
<path
|
||||||
|
d="M285.538 154.856h4.067v-27.818h-4.067Zm6.284 2.118h-8.503v-32.053h8.503z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
16
frontend/src/icons/SadFace.vue
Normal file
16
frontend/src/icons/SadFace.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
14
frontend/src/index.css
Normal file
14
frontend/src/index.css
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
64
frontend/src/lib/covergen.ts
Normal file
64
frontend/src/lib/covergen.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
interface CoverArgs {
|
||||||
|
customer: string;
|
||||||
|
number: string;
|
||||||
|
numberPrefix?: string;
|
||||||
|
hlColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CoverError {
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
generateCover(args: CoverArgs): Uint8Array | CoverError;
|
||||||
|
|
||||||
|
generateSplitCover(
|
||||||
|
args: CoverArgs
|
||||||
|
): { front: Uint8Array; back: Uint8Array } | CoverError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load and run the WASM blob
|
||||||
|
const go = new Go();
|
||||||
|
WebAssembly.instantiateStreaming(fetch('covergen.wasm'), go.importObject).then(
|
||||||
|
(result) => {
|
||||||
|
go.run(result.instance);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* generateCover generates a pdf containing both the front and back page for the invoice.
|
||||||
|
* If you want them separately, call generateSplitCover.
|
||||||
|
*
|
||||||
|
* @see generateSplitCover
|
||||||
|
* @param args
|
||||||
|
*/
|
||||||
|
export function generateCover(args: CoverArgs): File {
|
||||||
|
const result = window.generateCover(args);
|
||||||
|
if ('error' in result) {
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new File([result], 'cover.pdf', { type: 'application/pdf' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* generateSplitCover generates two pdfs, one for the front cover, and one for the back
|
||||||
|
* @see generateCover
|
||||||
|
* @param args
|
||||||
|
*/
|
||||||
|
export function generateSplitCover(args: CoverArgs): {
|
||||||
|
front: File;
|
||||||
|
back: File;
|
||||||
|
} {
|
||||||
|
const result = window.generateSplitCover(args);
|
||||||
|
if ('error' in result) {
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
front: new File([result.front], 'front.pdf', { type: 'application/pdf' }),
|
||||||
|
back: new File([result.back], 'back.pdf', { type: 'application/pdf' }),
|
||||||
|
};
|
||||||
|
}
|
||||||
28
frontend/src/lib/randomlabel.ts
Normal file
28
frontend/src/lib/randomlabel.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { readonly, Ref, ref } from 'vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Random labels as a library function.
|
||||||
|
* Will return a random label from the given options.
|
||||||
|
* Calling the `update` function that is returned will pick a new label.
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
export function randomLabel(options: string[]) {
|
||||||
|
const label = ref<string>();
|
||||||
|
const randomLabel = () => {
|
||||||
|
let newLabel;
|
||||||
|
do {
|
||||||
|
newLabel = options[Math.floor(Math.random() * options.length)];
|
||||||
|
} while (newLabel === label.value);
|
||||||
|
return newLabel;
|
||||||
|
};
|
||||||
|
label.value = randomLabel();
|
||||||
|
|
||||||
|
return {
|
||||||
|
// This type refinement is proper as the `undefined` value can never happen from this point onwards
|
||||||
|
label: readonly(label as Ref<string>),
|
||||||
|
// Updates the returned `label` with a new randomly chosen one
|
||||||
|
update() {
|
||||||
|
label.value = randomLabel();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
7
frontend/src/main.ts
Normal file
7
frontend/src/main.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import App from './App.vue';
|
||||||
|
import './index.css';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
|
import './lib/covergen'; // Get that go app booting
|
||||||
|
|
||||||
|
createApp(App).use(createPinia()).mount('#app');
|
||||||
28
frontend/src/stores/cover.ts
Normal file
28
frontend/src/stores/cover.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { generateSplitCover } from '@/lib/covergen';
|
||||||
|
|
||||||
|
export const useCover = defineStore('coverSettings', {
|
||||||
|
state() {
|
||||||
|
return {
|
||||||
|
customer: '',
|
||||||
|
prefix: 'offerte',
|
||||||
|
number: '',
|
||||||
|
color: '#ff00ff',
|
||||||
|
|
||||||
|
frontUri: '',
|
||||||
|
backUri: '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
render() {
|
||||||
|
const { front, back } = generateSplitCover({
|
||||||
|
customer: this.customer,
|
||||||
|
numberPrefix: this.prefix,
|
||||||
|
number: this.number,
|
||||||
|
hlColor: this.color,
|
||||||
|
});
|
||||||
|
this.frontUri = URL.createObjectURL(front);
|
||||||
|
this.backUri = URL.createObjectURL(back);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
21
frontend/tailwind.config.js
Normal file
21
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const defaultTheme = require('tailwindcss/defaultTheme');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{vue,js,ts}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter var', ...defaultTheme.fontFamily.sans],
|
||||||
|
},
|
||||||
|
aspectRatio: {
|
||||||
|
'A4': '1 / 1.4142',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
require('@tailwindcss/forms'),
|
||||||
|
],
|
||||||
|
}
|
||||||
24
frontend/tsconfig.json
Normal file
24
frontend/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"sourceMap": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"lib": ["esnext", "dom"],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"exclude": [
|
||||||
|
"dist",
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
14
frontend/vite.config.ts
Normal file
14
frontend/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig(({ mode }) => ({
|
||||||
|
plugins: [vue()],
|
||||||
|
base: mode === 'production' ? '/covergen/' : '/',
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -2,20 +2,24 @@ package covergen
|
|||||||
|
|
||||||
import "github.com/go-pdf/fpdf"
|
import "github.com/go-pdf/fpdf"
|
||||||
|
|
||||||
|
// Color represents a simple RGB color
|
||||||
type Color struct {
|
type Color struct {
|
||||||
R uint8
|
R uint8
|
||||||
G uint8
|
G uint8
|
||||||
B 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) {
|
func (c Color) Text(pdf *fpdf.Fpdf) {
|
||||||
pdf.SetTextColor(int(c.R), int(c.G), int(c.B))
|
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) {
|
func (c Color) Draw(pdf *fpdf.Fpdf) {
|
||||||
pdf.SetDrawColor(int(c.R), int(c.G), int(c.B))
|
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) {
|
func (c Color) Fill(pdf *fpdf.Fpdf) {
|
||||||
pdf.SetFillColor(int(c.R), int(c.G), int(c.B))
|
pdf.SetFillColor(int(c.R), int(c.G), int(c.B))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,19 +6,34 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-pdf/fpdf"
|
"github.com/go-pdf/fpdf"
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// page margins
|
||||||
const margin = 20
|
const margin = 20
|
||||||
const miwebbLogoName = "miwebbLogo"
|
|
||||||
const miwebbLogoPath = "miwebb.white.png"
|
|
||||||
const logoScale = 0.6
|
|
||||||
|
|
||||||
const gridXTicks = 10
|
// constants for the logo
|
||||||
const gridYTicks = 6
|
const (
|
||||||
const gridMargin = 5
|
// pdf internal name to reference the logo
|
||||||
const lineHeight = 1.2
|
logoName = "miwebbLogo"
|
||||||
const maxFontSize = 100
|
// 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 bgColor = Color{25, 25, 25}
|
||||||
var textColor = Color{255, 255, 255}
|
var textColor = Color{255, 255, 255}
|
||||||
@@ -28,12 +43,18 @@ var gridNoduleColor = Color{255, 255, 255}
|
|||||||
//go:embed miwebb.white.png font_embed
|
//go:embed miwebb.white.png font_embed
|
||||||
var fs embed.FS
|
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
|
const (
|
||||||
// constant for mm is just to ensure you don't change it without noting the scale factor that depends on it
|
// Copied from FPDF's source, can't get it dynamically
|
||||||
const scaleUnit = "mm"
|
// constant for mm is just to ensure you don't change it without noting the scale factor that depends on it
|
||||||
const scaleFactor = 72.0 / 25.4
|
scaleUnit = "mm"
|
||||||
|
scaleFactor = 72.0 / 25.4
|
||||||
|
)
|
||||||
|
|
||||||
type CoverSettings struct {
|
type CoverSettings struct {
|
||||||
Number string
|
Number string
|
||||||
@@ -43,36 +64,16 @@ type CoverSettings struct {
|
|||||||
HLColor Color
|
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) {
|
func GenerateInvoice(settings CoverSettings) (*fpdf.Fpdf, error) {
|
||||||
pdf := fpdf.New("P", scaleUnit, "A4", "")
|
pdf, err := GenerateFrontCover(settings)
|
||||||
|
|
||||||
if err := addEmbeddedFont(pdf, "Mark Medium", "", "Mark-Medium"); err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("Error adding font")
|
|
||||||
}
|
|
||||||
if err := addEmbeddedFont(pdf, "Mark Light", "", "Mark-Light"); err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("Error adding font")
|
|
||||||
}
|
|
||||||
miwebbLogo, err := addEmbeddedLogo(pdf, miwebbLogoName, miwebbLogoPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("Error adding logo")
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.SetMargins(margin, margin, margin)
|
// Generating the back cover from the front cover is just 'drawing the customer on a new page'
|
||||||
pageWidth, pageHeight := pdf.GetPageSize()
|
// as all the other things are automatically done using the page setup handlers
|
||||||
pdf.SetAutoPageBreak(false, 0)
|
|
||||||
|
|
||||||
pdf.SetHeaderFunc(func() {
|
|
||||||
bgColor.Fill(pdf)
|
|
||||||
pdf.Rect(0, 0, pageWidth, pageHeight, "F")
|
|
||||||
pdf.Image(miwebbLogoName, margin-5, margin, miwebbLogo.Width()*logoScale, miwebbLogo.Height()*logoScale, false, "", 0, "")
|
|
||||||
})
|
|
||||||
|
|
||||||
pdf.AddPage()
|
|
||||||
|
|
||||||
drawCustomerName(pdf, settings)
|
|
||||||
drawInvoiceNumber(pdf, settings)
|
|
||||||
drawMiwebbLink(pdf)
|
|
||||||
|
|
||||||
pdf.AddPage()
|
pdf.AddPage()
|
||||||
drawCustomerName(pdf, settings)
|
drawCustomerName(pdf, settings)
|
||||||
|
|
||||||
@@ -82,11 +83,85 @@ func GenerateInvoice(settings CoverSettings) (*fpdf.Fpdf, error) {
|
|||||||
|
|
||||||
return pdf, nil
|
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 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
drawInvoiceNumber(pdf, settings)
|
||||||
|
drawMiwebbLink(pdf)
|
||||||
|
|
||||||
|
if pdf.Err() {
|
||||||
|
return nil, pdf.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 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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", "")
|
||||||
|
|
||||||
|
if err := addEmbeddedFont(pdf, "Mark Medium", "", "Mark-Medium"); err != nil {
|
||||||
|
return nil, fmt.Errorf("error adding font: %w", err)
|
||||||
|
}
|
||||||
|
if err := addEmbeddedFont(pdf, "Mark Light", "", "Mark-Light"); err != nil {
|
||||||
|
return nil, fmt.Errorf("error adding font: %w", err)
|
||||||
|
}
|
||||||
|
logo, err := addEmbeddedLogo(pdf, logoName, logoPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error adding logo: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.SetMargins(margin, margin, margin)
|
||||||
|
pageWidth, pageHeight := pdf.GetPageSize()
|
||||||
|
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")
|
||||||
|
// Draws the MiWebb logo
|
||||||
|
pdf.Image(logoName, margin-5, margin, logo.Width()*logoScale, logo.Height()*logoScale, false, "", 0, "")
|
||||||
|
})
|
||||||
|
|
||||||
|
pdf.AddPage()
|
||||||
|
drawCustomerName(pdf, settings)
|
||||||
|
|
||||||
|
if pdf.Err() {
|
||||||
|
return nil, fmt.Errorf("error generating invoice: %w", pdf.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) {
|
func drawInvoiceNumber(pdf *fpdf.Fpdf, settings CoverSettings) {
|
||||||
_, pageHeight := pdf.GetPageSize()
|
_, pageHeight := pdf.GetPageSize()
|
||||||
|
|
||||||
|
// Bottom left corner of the page, given margin
|
||||||
pdf.MoveTo(margin, pageHeight-margin)
|
pdf.MoveTo(margin, pageHeight-margin)
|
||||||
pdf.SetFont("Mark Light", "", 9)
|
pdf.SetFont(fontLight, "", 9)
|
||||||
textColor.Text(pdf)
|
textColor.Text(pdf)
|
||||||
|
|
||||||
pdf.TransformBegin()
|
pdf.TransformBegin()
|
||||||
@@ -95,37 +170,44 @@ func drawInvoiceNumber(pdf *fpdf.Fpdf, settings CoverSettings) {
|
|||||||
pdf.CellFormat(0, 1, invoiceText, "", 0, "LT", false, 0, "")
|
pdf.CellFormat(0, 1, invoiceText, "", 0, "LT", false, 0, "")
|
||||||
pdf.TransformEnd()
|
pdf.TransformEnd()
|
||||||
|
|
||||||
|
// Calculate where the text has gone, so we can figure out the position of the line behind it
|
||||||
_, h := pdf.GetFontSize()
|
_, 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
|
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
|
y := pageHeight - margin - pdf.GetStringWidth(invoiceText) - 5
|
||||||
|
|
||||||
textColor.Draw(pdf)
|
textColor.Draw(pdf)
|
||||||
pdf.SetLineWidth(0.01)
|
pdf.SetLineWidth(0.01)
|
||||||
|
// the 10 here is the length of the line segment, chosen arbitrarily
|
||||||
pdf.Line(x, y, x, y-10)
|
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) {
|
func drawMiwebbLink(pdf *fpdf.Fpdf) {
|
||||||
pageWidth, pageHeight := pdf.GetPageSize()
|
pageWidth, pageHeight := pdf.GetPageSize()
|
||||||
pdf.MoveTo(pageWidth-margin, pageHeight-margin)
|
pdf.MoveTo(pageWidth-margin, pageHeight-margin)
|
||||||
|
|
||||||
pdf.SetFont("Mark Light", "", 9)
|
pdf.SetFont(fontLight, "", 9)
|
||||||
textColor.Text(pdf)
|
textColor.Text(pdf)
|
||||||
pdf.CellFormat(0, 0, "WWW.MIWEBB.COM", "", 0, "RB", false, 0, "https://www.miwebb.com")
|
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) {
|
func drawCustomerName(pdf *fpdf.Fpdf, settings CoverSettings) {
|
||||||
ml, _, mr, _ := pdf.GetMargins()
|
ml, _, mr, _ := pdf.GetMargins()
|
||||||
pw, ph := pdf.GetPageSize()
|
pw, ph := pdf.GetPageSize()
|
||||||
|
|
||||||
|
// Calculate the width and height of the grid, as we'll need it to position it
|
||||||
width := pw - ml - mr
|
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
|
height := (width/gridXTicks - 1) * gridYTicks
|
||||||
text := strings.ToUpper(settings.CustomerName)
|
text := strings.ToUpper(settings.CustomerName)
|
||||||
|
|
||||||
// Draw the grid with a weird gray color
|
// Draw the grid with a weird gray color
|
||||||
gridColor.Draw(pdf)
|
gridColor.Draw(pdf)
|
||||||
pdf.SetLineWidth(0.01)
|
pdf.SetLineWidth(0.01)
|
||||||
|
// Calculates the top left corner of the grid
|
||||||
gridX, gridY := ml, (ph/2)-(height/2)
|
gridX, gridY := ml, (ph/2)-(height/2)
|
||||||
drawGrid(pdf, gridX, gridY, width, height, gridXTicks, gridYTicks)
|
drawGrid(pdf, gridX, gridY, width, height, gridXTicks, gridYTicks)
|
||||||
|
|
||||||
@@ -137,22 +219,25 @@ func drawCustomerName(pdf *fpdf.Fpdf, settings CoverSettings) {
|
|||||||
1.2, gridNoduleColor,
|
1.2, gridNoduleColor,
|
||||||
0.25, settings.HLColor)
|
0.25, settings.HLColor)
|
||||||
|
|
||||||
pdf.SetFont("Mark Medium", "", 10)
|
pdf.SetFont(fontMedium, "", 10)
|
||||||
// Estimate font size, and calculate line height immediately after
|
// Estimate font size, and calculate line height immediately after
|
||||||
pdf.SetFontSize(estimateFontSize(pdf, text, width, height, gridMargin))
|
pdf.SetFontSize(estimateFontSize(pdf, text, width, height, gridPadding))
|
||||||
_, sz := pdf.GetFontSize()
|
_, fontHeight := pdf.GetFontSize()
|
||||||
sz *= lineHeight
|
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:
|
// Centered vertically:
|
||||||
// x = <gridX> + <margin>
|
// x = <gridX> + <margin>
|
||||||
// y = <gridY> + <gridH>/2 - textHeight/2
|
// y = <gridY> + <gridH>/2 - textHeight/2
|
||||||
|
pdf.MoveTo(ml+gridPadding, ph/2-(textHeight/2))
|
||||||
pdf.MoveTo(ml+gridMargin, ph/2-(textHeight/2))
|
|
||||||
textColor.Text(pdf)
|
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) {
|
func drawGrid(pdf *fpdf.Fpdf, x, y, w, h float64, xs, ys int) {
|
||||||
// Horizontal lines
|
// Horizontal lines
|
||||||
for i := 0; i < ys; i++ {
|
for i := 0; i < ys; i++ {
|
||||||
@@ -160,13 +245,14 @@ func drawGrid(pdf *fpdf.Fpdf, x, y, w, h float64, xs, ys int) {
|
|||||||
pdf.Line(x, yOff, x+w, yOff)
|
pdf.Line(x, yOff, x+w, yOff)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vertical line(pw/2)-(width/2)s
|
// Vertical lines
|
||||||
for i := 0; i < xs; i++ {
|
for i := 0; i < xs; i++ {
|
||||||
xOff := x + (w/float64(xs-1))*float64(i)
|
xOff := x + (w/float64(xs-1))*float64(i)
|
||||||
pdf.Line(xOff, y, xOff, y+h)
|
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) {
|
func drawNoduleLine(pdf *fpdf.Fpdf, x1, y1, x2, y2, squareSize float64, squareColor Color, lineWidth float64, lineColor Color) {
|
||||||
lineColor.Draw(pdf)
|
lineColor.Draw(pdf)
|
||||||
pdf.SetLineWidth(lineWidth)
|
pdf.SetLineWidth(lineWidth)
|
||||||
@@ -177,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")
|
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 {
|
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
|
maxWidth := containerWidth - 3*margin
|
||||||
maxHeight := containerHeight - 3*margin
|
maxHeight := containerHeight - 3*margin
|
||||||
|
|
||||||
// Probably smallest we can start with
|
// Probably smallest we can start with
|
||||||
for fontSize := 10.; fontSize <= maxFontSize; fontSize += 1 {
|
for fontSize := 10.; fontSize <= maxFontSize; fontSize += 1 {
|
||||||
width, height := stringSize(pdf, text, (fontSize/scaleFactor)*lineHeight, fontSize)
|
width, height := stringSize(pdf, text, (fontSize/scaleFactor)*lineHeight, fontSize)
|
||||||
|
// Return the last font size if this one overflows
|
||||||
if width >= maxWidth || height >= maxHeight {
|
if width >= maxWidth || height >= maxHeight {
|
||||||
return fontSize - 1
|
return fontSize - 1
|
||||||
}
|
}
|
||||||
@@ -193,6 +285,9 @@ func estimateFontSize(pdf *fpdf.Fpdf, text string, containerWidth, containerHeig
|
|||||||
return maxFontSize
|
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) {
|
func stringSize(pdf *fpdf.Fpdf, text string, lineHeight, fontSizePt float64) (width, height float64) {
|
||||||
if fontSizePt == 0 {
|
if fontSizePt == 0 {
|
||||||
fontSizePt, _ = pdf.GetFontSize()
|
fontSizePt, _ = pdf.GetFontSize()
|
||||||
@@ -210,6 +305,8 @@ func stringSize(pdf *fpdf.Fpdf, text string, lineHeight, fontSizePt float64) (wi
|
|||||||
return
|
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 {
|
func addEmbeddedFont(pdf *fpdf.Fpdf, family, style, path string) error {
|
||||||
jsonData, err := fs.ReadFile(fontsEmbedPrefix + path + ".json")
|
jsonData, err := fs.ReadFile(fontsEmbedPrefix + path + ".json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -228,6 +325,8 @@ func addEmbeddedFont(pdf *fpdf.Fpdf, family, style, path string) error {
|
|||||||
return nil
|
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) {
|
func addEmbeddedLogo(pdf *fpdf.Fpdf, name, path string) (*fpdf.ImageInfoType, error) {
|
||||||
reader, err := fs.Open(path)
|
reader, err := fs.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user