Compare commits
21 Commits
39af776d00
...
f7dd929bdd
| Author | SHA1 | Date | |
|---|---|---|---|
| f7dd929bdd | |||
| 30aba3c871 | |||
| cb8400eea3 | |||
| 63875a6f59 | |||
| b88208ba3d | |||
| 5ceadd09dd | |||
| 7387f33678 | |||
| f4c704740a | |||
| 0c6cdcf1f5 | |||
| 6bfa971b0a | |||
| 809fa354c4 | |||
| e0efd9dc41 | |||
| 68756014be | |||
| ff22127baa | |||
| 79f8adaf1a | |||
| 4413fab9ad | |||
| e89acf585f | |||
| 2a0bfd0e10 | |||
| ac4b86fd03 | |||
| f05c4bbcc7 | |||
| d2ac081d5e |
77
.drone.yml
77
.drone.yml
@@ -1,9 +1,82 @@
|
||||
---
|
||||
kind: pipeline
|
||||
type: kubernetes
|
||||
name: default
|
||||
name: build-clis
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
|
||||
steps:
|
||||
- name: build
|
||||
image: golang:1.17
|
||||
commands:
|
||||
- go build
|
||||
- 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
|
||||
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
covergen
|
||||
!covergen/
|
||||
*.pdf
|
||||
assets/*.wasm
|
||||
*.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:
|
||||
GOOS=js GOARCH=wasm go build -o assets/covergen.wasm ./cmd/wasm/main.go
|
||||
.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: ## 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.
|
||||
@@ -1,93 +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.generateCover(args);
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return new File([result], 'cover.pdf', {type: 'application/pdf'});
|
||||
}
|
||||
|
||||
function makeSplitCover(args) {
|
||||
const result = window.generateSplitCover(args);
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return Object.fromEntries(['front', 'back'].map((side) => [side, new File([result[side]], `${side}.pdf`, {type: 'application/pdf'})]));
|
||||
}
|
||||
|
||||
function letsfuckinggo() {
|
||||
const covers = makeSplitCover({
|
||||
customer: document.getElementById('customer').value,
|
||||
number: document.getElementById('number').value,
|
||||
numberPrefix: document.getElementById('prefix').value,
|
||||
hlColor: document.getElementById('color').value,
|
||||
})
|
||||
|
||||
document.getElementById('front').src = window.URL.createObjectURL(covers.front);
|
||||
document.getElementById('back').src = window.URL.createObjectURL(covers.back);
|
||||
}
|
||||
|
||||
</script>
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.covers {
|
||||
height: 100%;
|
||||
width: 90%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.covers iframe {
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<label>
|
||||
Customer:
|
||||
<textarea id="customer"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
Prefix:
|
||||
<input id="prefix" type="text" value="offerte">
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
Number:
|
||||
<input id="number" type="text">
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
Color:
|
||||
<input id="color" type="color" value="#FF69B4">
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<button onclick="letsfuckinggo()">Fuck it!</button>
|
||||
</div>
|
||||
<div class="covers">
|
||||
<iframe id="front"></iframe>
|
||||
<iframe id="back"></iframe>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,13 @@ import (
|
||||
"fmt"
|
||||
"syscall/js"
|
||||
|
||||
"github.com/go-pdf/fpdf"
|
||||
"github.com/thegrumpylion/jsref"
|
||||
|
||||
"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 {
|
||||
@@ -20,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:
|
||||
@@ -32,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",
|
||||
@@ -70,10 +75,24 @@ 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 {
|
||||
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{} {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Println("recovered", r)
|
||||
fmt.Printf("recovered from critical error: %+v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -96,18 +115,61 @@ func generateCover(this js.Value, args []js.Value) interface{} {
|
||||
return jsmap{"error": err.Error()}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err = pdf.Output(&buf); err != nil {
|
||||
jsBytes, err := pdfToJs(pdf)
|
||||
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()}
|
||||
}
|
||||
|
||||
s := buf.Bytes()
|
||||
ta := js.Global().Get("Uint8Array").New(len(s))
|
||||
js.CopyBytesToJS(ta, s)
|
||||
return ta
|
||||
front, err := covergen.GenerateFrontCover(*settings)
|
||||
if err != nil {
|
||||
return jsmap{"error": fmt.Sprintf("failed to render front: %s", err.Error())}
|
||||
}
|
||||
|
||||
back, err := covergen.GenerateBackCover(*settings)
|
||||
if err != nil {
|
||||
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() {
|
||||
js.Global().Set("covergen", js.FuncOf(generateCover))
|
||||
js.Global().Set("generateCover", js.FuncOf(generateCover))
|
||||
js.Global().Set("generateSplitCover", js.FuncOf(generateSplitCover))
|
||||
<-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"
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user