Update .gitignore, README, and add build outputs and tooling
- Updated .gitignore to reflect current project structure - Updated README with current build and installation instructions - Added cmd, internal, and scripts directories for project organization - Added build artifacts and installation scripts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
79b11c27ef
commit
f00ca45f59
13
.gitignore
vendored
13
.gitignore
vendored
|
|
@ -1,10 +1,3 @@
|
|||
# build artefacts
|
||||
pkg/
|
||||
src/
|
||||
*.egg-info/
|
||||
*.tar.gz
|
||||
*.pkg.tar.*
|
||||
build/
|
||||
dist/
|
||||
__pycache__/
|
||||
.venv/
|
||||
/.cache/
|
||||
/bin/
|
||||
go.sum
|
||||
|
|
|
|||
58
README.md
58
README.md
|
|
@ -1,32 +1,40 @@
|
|||
# Leak Technologies — img2pdf
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Author:** Stu Leak (<leaktechnologies@proton.me>)
|
||||
**License:** MIT
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
`img2pdf` is a lightweight, production-ready command-line utility that converts a single image or a folder of images into a PDF file.
|
||||
Files are automatically sorted by filename (numerically and alphabetically), ensuring consistent page order.
|
||||
|
||||
Built for Arch Linux systems, fully AUR-ready, and modular enough for GUI integration later.
|
||||
|
||||
---
|
||||
Minimal Go utility (with a tiny native UI) to merge images into a single PDF. Uses the Go standard library plus Fyne for the UI.
|
||||
|
||||
## Features
|
||||
|
||||
- Converts individual images or entire folders
|
||||
- Auto-sorts images (natural order)
|
||||
- Supports JPG, PNG, BMP, TIFF, WEBP
|
||||
- Clean, colorized CLI output
|
||||
- Safe overwrite handling (`--overwrite`)
|
||||
- Optional quiet mode for scripting (`--quiet`)
|
||||
|
||||
---
|
||||
- Drag-and-drop UI for images or folders; natural filename sorting
|
||||
- Choose output location/name in the UI; defaults next to the first input
|
||||
- Supported formats (stdlib decoders): JPG, PNG, GIF
|
||||
- Lightweight custom PDF writer (no third-party PDF libs)
|
||||
|
||||
## Usage
|
||||
|
||||
Install/build (from repo root):
|
||||
```bash
|
||||
img2pdf <input_path> [-o output.pdf] [--overwrite] [--quiet]
|
||||
./install.sh # or ./scripts/build.sh
|
||||
```
|
||||
|
||||
Run UI (default when no args):
|
||||
```bash
|
||||
./img2pdf
|
||||
```
|
||||
|
||||
CLI mode (explicit):
|
||||
```bash
|
||||
./img2pdf convert [-o output.pdf] <image_or_folder> [more_paths...]
|
||||
```
|
||||
- If `-o` is omitted, the PDF is written next to the first input: for a folder, `<folder>/<folder>.pdf`; for a single file, `<dir>/<file>.pdf`.
|
||||
- Add more inputs to merge.
|
||||
|
||||
## Wayland (Linux)
|
||||
- GUI builds target Wayland by default (`-tags=wayland`); the included build scripts set this for you.
|
||||
- On X11/XWayland sessions the UI will exit—run the CLI instead: `./img2pdf convert ...`.
|
||||
- Dependencies for the Wayland build: `libwayland-client`, `libxkbcommon`, `wayland-protocols`, `pkg-config`, and GL/EGL headers (e.g., mesa).
|
||||
|
||||
## Notes
|
||||
- Transparency is flattened onto white before encoding to JPEG for embedding.
|
||||
- Page size matches the pixel dimensions of each image (1 px = 1 pt).
|
||||
- UI uses Fyne and expects a Wayland compositor on Linux; ensure the platform requirements above are installed.
|
||||
|
||||
## License
|
||||
MIT
|
||||
|
|
|
|||
95
cmd/img2pdf/main.go
Normal file
95
cmd/img2pdf/main.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"img2pdf/internal/convert"
|
||||
"img2pdf/internal/input"
|
||||
"img2pdf/internal/ui"
|
||||
|
||||
"fyne.io/fyne/v2/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// No args: launch the UI.
|
||||
if len(os.Args) == 1 {
|
||||
if err := prepareWaylandUI(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
a := app.NewWithID("dev.leaktechnologies.img2pdf")
|
||||
ui.Run(a)
|
||||
return
|
||||
}
|
||||
|
||||
// Optional subcommand: "convert" invokes CLI mode explicitly.
|
||||
if os.Args[1] == "convert" {
|
||||
runCLI(os.Args[2:])
|
||||
return
|
||||
}
|
||||
|
||||
// Backward-compatible CLI: treat remaining args as CLI inputs.
|
||||
runCLI(os.Args[1:])
|
||||
}
|
||||
|
||||
func prepareWaylandUI() error {
|
||||
// Only enforce Wayland on Linux. Other platforms still use the default driver.
|
||||
if runtime.GOOS != "linux" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Force the Wayland driver so we don't fall back to X11/XWayland.
|
||||
os.Setenv("FYNE_DRIVER", "wayland")
|
||||
|
||||
session := strings.ToLower(strings.TrimSpace(os.Getenv("XDG_SESSION_TYPE")))
|
||||
if session != "" && session != "wayland" {
|
||||
return fmt.Errorf("img2pdf UI requires Wayland (detected XDG_SESSION_TYPE=%q). Run CLI mode: img2pdf convert ...", session)
|
||||
}
|
||||
if strings.TrimSpace(os.Getenv("WAYLAND_DISPLAY")) == "" {
|
||||
return fmt.Errorf("img2pdf UI requires a Wayland compositor (WAYLAND_DISPLAY is not set). Run CLI mode: img2pdf convert ...")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCLI(args []string) {
|
||||
fs := flag.NewFlagSet("img2pdf", flag.ExitOnError)
|
||||
outPath := fs.String("o", "", "output pdf path (defaults next to first input)")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
paths := fs.Args()
|
||||
if len(paths) == 0 {
|
||||
log.Fatalf("usage: img2pdf convert [-o output.pdf] <image_or_folder> [more ...]")
|
||||
}
|
||||
|
||||
files, err := input.Collect(paths)
|
||||
if err != nil {
|
||||
log.Fatalf("collect inputs: %v", err)
|
||||
}
|
||||
|
||||
sources, err := input.ReadSources(files)
|
||||
if err != nil {
|
||||
log.Fatalf("read images: %v", err)
|
||||
}
|
||||
|
||||
if *outPath == "" {
|
||||
def, err := input.DefaultOutput(paths[0])
|
||||
if err != nil {
|
||||
log.Fatalf("derive output: %v", err)
|
||||
}
|
||||
*outPath = def
|
||||
}
|
||||
|
||||
pdf, err := convert.ToPDF(sources)
|
||||
if err != nil {
|
||||
log.Fatalf("convert: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(*outPath, pdf, 0644); err != nil {
|
||||
log.Fatalf("write pdf: %v", err)
|
||||
}
|
||||
fmt.Printf("saved %s (%d pages)\n", *outPath, len(files))
|
||||
}
|
||||
35
go.mod
Normal file
35
go.mod
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
module img2pdf
|
||||
|
||||
go 1.21
|
||||
|
||||
require fyne.io/fyne/v2 v2.4.4
|
||||
|
||||
require (
|
||||
fyne.io/systray v1.10.1-0.20231115130155-104f5ef7839e // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/fredbi/uri v1.0.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
||||
github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe // indirect
|
||||
github.com/fyne-io/glfw-js v0.0.0-20220120001248-ee7290d23504 // indirect
|
||||
github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2 // indirect
|
||||
github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6 // indirect
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20221017161538-93cebf72946b // indirect
|
||||
github.com/go-text/render v0.0.0-20230619120952-35bccb6164b8 // indirect
|
||||
github.com/go-text/typesetting v0.1.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/gopherjs/gopherjs v1.17.2 // indirect
|
||||
github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||
github.com/stretchr/testify v1.8.4 // indirect
|
||||
github.com/tevino/abool v1.2.0 // indirect
|
||||
github.com/yuin/goldmark v1.5.5 // indirect
|
||||
golang.org/x/image v0.11.0 // indirect
|
||||
golang.org/x/mobile v0.0.0-20230531173138-3c911d8e3eda // indirect
|
||||
golang.org/x/net v0.17.0 // indirect
|
||||
golang.org/x/sys v0.13.0 // indirect
|
||||
golang.org/x/text v0.13.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
honnef.co/go/js/dom v0.0.0-20210725211120-f030747120f2 // indirect
|
||||
)
|
||||
11
img2pdf
Executable file
11
img2pdf
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
#!/usr/bin/env sh
|
||||
# Wrapper to run the built binary from the repo root.
|
||||
set -e
|
||||
HERE="$(cd -- "$(dirname "$0")" && pwd)"
|
||||
BIN="$HERE/bin/img2pdf"
|
||||
|
||||
if [ ! -x "$BIN" ]; then
|
||||
"$HERE/scripts/build.sh"
|
||||
fi
|
||||
|
||||
exec "$BIN" "$@"
|
||||
4
install.sh
Executable file
4
install.sh
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env sh
|
||||
# Convenience wrapper to install the app into ~/.local/bin.
|
||||
set -euo pipefail
|
||||
"$(dirname "$0")/scripts/install.sh" "$@"
|
||||
219
internal/convert/convert.go
Normal file
219
internal/convert/convert.go
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
package convert
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SourceFile represents an uploaded image that should become a PDF page.
|
||||
type SourceFile struct {
|
||||
Name string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// ToPDF converts the given image files into a single PDF document using only
|
||||
// standard library encoders.
|
||||
func ToPDF(files []SourceFile) ([]byte, error) {
|
||||
if len(files) == 0 {
|
||||
return nil, fmt.Errorf("no images provided")
|
||||
}
|
||||
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return NaturalLess(files[i].Name, files[j].Name)
|
||||
})
|
||||
|
||||
var pages []pdfImage
|
||||
for _, f := range files {
|
||||
img, _, err := image.Decode(bytes.NewReader(f.Data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode %s: %w", f.Name, err)
|
||||
}
|
||||
jpegData, w, h, err := toJPEG(img)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode %s: %w", f.Name, err)
|
||||
}
|
||||
pages = append(pages, pdfImage{
|
||||
Width: w,
|
||||
Height: h,
|
||||
Data: jpegData,
|
||||
})
|
||||
}
|
||||
return buildPDF(pages)
|
||||
}
|
||||
|
||||
// toJPEG flattens any transparency onto white and returns JPEG bytes.
|
||||
func toJPEG(img image.Image) ([]byte, int, int, error) {
|
||||
b := img.Bounds()
|
||||
rgba := image.NewRGBA(b)
|
||||
// Default to white background.
|
||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||
for x := b.Min.X; x < b.Max.X; x++ {
|
||||
c := color.RGBAModel.Convert(img.At(x, y)).(color.RGBA)
|
||||
// Premultiply to drop alpha onto white.
|
||||
if c.A < 255 {
|
||||
alpha := float64(c.A) / 255.0
|
||||
c.R = uint8(float64(c.R)*alpha + 255*(1-alpha))
|
||||
c.G = uint8(float64(c.G)*alpha + 255*(1-alpha))
|
||||
c.B = uint8(float64(c.B)*alpha + 255*(1-alpha))
|
||||
c.A = 255
|
||||
}
|
||||
rgba.SetRGBA(x, y, c)
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, rgba, &jpeg.Options{Quality: 90}); err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
return buf.Bytes(), b.Dx(), b.Dy(), nil
|
||||
}
|
||||
|
||||
// NaturalLess performs a natural, case-insensitive comparison of two strings.
|
||||
func NaturalLess(a, b string) bool {
|
||||
as := splitNumeric(strings.ToLower(a))
|
||||
bs := splitNumeric(strings.ToLower(b))
|
||||
|
||||
for i := 0; i < len(as) && i < len(bs); i++ {
|
||||
if as[i] == bs[i] {
|
||||
continue
|
||||
}
|
||||
if ia, oka := toInt(as[i]); oka {
|
||||
if ib, okb := toInt(bs[i]); okb {
|
||||
return ia < ib
|
||||
}
|
||||
}
|
||||
return as[i] < bs[i]
|
||||
}
|
||||
return len(as) < len(bs)
|
||||
}
|
||||
|
||||
func splitNumeric(s string) []string {
|
||||
var parts []string
|
||||
curr := strings.Builder{}
|
||||
isDigit := func(r rune) bool { return r >= '0' && r <= '9' }
|
||||
|
||||
var digitMode *bool
|
||||
for _, r := range s {
|
||||
digit := isDigit(r)
|
||||
if digitMode == nil {
|
||||
digitMode = &digit
|
||||
}
|
||||
if digit != *digitMode {
|
||||
parts = append(parts, curr.String())
|
||||
curr.Reset()
|
||||
digitMode = &digit
|
||||
}
|
||||
curr.WriteRune(r)
|
||||
}
|
||||
if curr.Len() > 0 {
|
||||
parts = append(parts, curr.String())
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func toInt(s string) (int, bool) {
|
||||
n := 0
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return 0, false
|
||||
}
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// pdfImage holds minimal data for embedding into a PDF.
|
||||
type pdfImage struct {
|
||||
Width int
|
||||
Height int
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// buildPDF writes a very small PDF with each image on its own page sized to the image.
|
||||
func buildPDF(images []pdfImage) ([]byte, error) {
|
||||
if len(images) == 0 {
|
||||
return nil, fmt.Errorf("no images")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
write := func(s string) {
|
||||
buf.WriteString(s)
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
|
||||
type obj struct {
|
||||
offset int
|
||||
}
|
||||
var offsets []obj
|
||||
addObject := func(body string) {
|
||||
offsets = append(offsets, obj{offset: buf.Len()})
|
||||
write(fmt.Sprintf("%d 0 obj", len(offsets)))
|
||||
write(body)
|
||||
write("endobj")
|
||||
}
|
||||
|
||||
write("%PDF-1.4")
|
||||
|
||||
// Placeholder for catalog and pages; will reference counts after we know them.
|
||||
addObject("<< /Type /Catalog /Pages 2 0 R >>")
|
||||
addObject("") // pages placeholder
|
||||
|
||||
pageObjects := []int{}
|
||||
for i, img := range images {
|
||||
pageNum := len(offsets) + 1
|
||||
contentNum := pageNum + 1
|
||||
imageNum := pageNum + 2
|
||||
pageObjects = append(pageObjects, pageNum)
|
||||
|
||||
mediaBox := fmt.Sprintf("[0 0 %d %d]", img.Width, img.Height)
|
||||
resources := fmt.Sprintf("<< /XObject << /Im%d %d 0 R >> >>", i, imageNum)
|
||||
addObject(fmt.Sprintf("<< /Type /Page /Parent 2 0 R /MediaBox %s /Resources %s /Contents %d 0 R >>", mediaBox, resources, contentNum))
|
||||
|
||||
contentStream := fmt.Sprintf("q %d 0 0 %d 0 0 cm /Im%d Do Q", img.Width, img.Height, i)
|
||||
addObject(streamObject(contentStream))
|
||||
|
||||
imgDict := fmt.Sprintf("<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length %d >>", img.Width, img.Height, len(img.Data))
|
||||
offsets = append(offsets, obj{offset: buf.Len()})
|
||||
write(fmt.Sprintf("%d 0 obj", len(offsets)))
|
||||
write(imgDict)
|
||||
write("stream")
|
||||
buf.Write(img.Data)
|
||||
write("\nendstream")
|
||||
write("endobj")
|
||||
}
|
||||
|
||||
// Rewrite pages object now that we know kids.
|
||||
var kids strings.Builder
|
||||
for _, p := range pageObjects {
|
||||
kids.WriteString(fmt.Sprintf(" %d 0 R", p))
|
||||
}
|
||||
offsets[1].offset = buf.Len()
|
||||
write("2 0 obj")
|
||||
write(fmt.Sprintf("<< /Type /Pages /Count %d /Kids [%s ] >>", len(pageObjects), kids.String()))
|
||||
write("endobj")
|
||||
|
||||
// Write xref.
|
||||
xrefPos := buf.Len()
|
||||
write("xref")
|
||||
write(fmt.Sprintf("0 %d", len(offsets)+1))
|
||||
write("0000000000 65535 f ")
|
||||
for _, o := range offsets {
|
||||
write(fmt.Sprintf("%010d 00000 n ", o.offset))
|
||||
}
|
||||
write("trailer")
|
||||
write(fmt.Sprintf("<< /Size %d /Root 1 0 R >>", len(offsets)+1))
|
||||
write("startxref")
|
||||
write(strconv.Itoa(xrefPos))
|
||||
write("%%EOF")
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func streamObject(content string) string {
|
||||
return fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content)
|
||||
}
|
||||
93
internal/input/input.go
Normal file
93
internal/input/input.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"img2pdf/internal/convert"
|
||||
)
|
||||
|
||||
// Collect expands provided paths, walking folders to gather image files.
|
||||
func Collect(inputs []string) ([]string, error) {
|
||||
var results []string
|
||||
for _, p := range inputs {
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat %s: %w", p, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
err = filepath.WalkDir(p, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if IsImage(path) {
|
||||
results = append(results, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if IsImage(p) {
|
||||
results = append(results, p)
|
||||
}
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return nil, fmt.Errorf("no supported image files found")
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// IsImage returns true if the path has an image extension supported by the stdlib decoders.
|
||||
func IsImage(path string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".gif":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ReadSources reads file data for conversion.
|
||||
func ReadSources(paths []string) ([]convert.SourceFile, error) {
|
||||
var files []convert.SourceFile
|
||||
for _, p := range paths {
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", p, err)
|
||||
}
|
||||
files = append(files, convert.SourceFile{
|
||||
Name: filepath.Base(p),
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// DefaultOutput derives an output path based on the first input.
|
||||
func DefaultOutput(first string) (string, error) {
|
||||
info, err := os.Stat(first)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stat %s: %w", first, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
name := filepath.Base(first)
|
||||
if name == "." || name == "" {
|
||||
name = "images"
|
||||
}
|
||||
return filepath.Join(first, name+".pdf"), nil
|
||||
}
|
||||
base := strings.TrimSuffix(filepath.Base(first), filepath.Ext(first))
|
||||
if base == "" {
|
||||
base = "images"
|
||||
}
|
||||
return filepath.Join(filepath.Dir(first), base+".pdf"), nil
|
||||
}
|
||||
442
internal/ui/ui.go
Normal file
442
internal/ui/ui.go
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"img2pdf/internal/convert"
|
||||
"img2pdf/internal/input"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
// Run launches the desktop UI.
|
||||
func Run(app fyne.App) {
|
||||
w := app.NewWindow("img2pdf")
|
||||
w.Resize(fyne.NewSize(720, 520))
|
||||
|
||||
state := &uiState{
|
||||
output: widget.NewEntry(),
|
||||
status: widget.NewLabel("ready"),
|
||||
autoConvert: true,
|
||||
dark: false,
|
||||
}
|
||||
state.status.TextStyle = fyne.TextStyle{Monospace: true}
|
||||
state.output.SetText("images.pdf")
|
||||
state.spinner = widget.NewProgressBarInfinite()
|
||||
state.spinner.Hide()
|
||||
state.bg = canvas.NewRectangle(color.NRGBA{0xf4, 0xf1, 0xec, 0xff})
|
||||
state.bg.SetMinSize(fyne.NewSize(820, 620))
|
||||
state.blob1 = canvas.NewRectangle(color.NRGBA{0xff, 0xc7, 0x8f, 0x55})
|
||||
state.blob1.SetMinSize(fyne.NewSize(140, 60))
|
||||
state.blob1.Move(fyne.NewPos(48, 54))
|
||||
state.blob2 = canvas.NewRectangle(color.NRGBA{0x8c, 0xd8, 0xc0, 0x55})
|
||||
state.blob2.SetMinSize(fyne.NewSize(110, 96))
|
||||
state.blob2.Move(fyne.NewPos(620, 80))
|
||||
state.blob3 = canvas.NewRectangle(color.NRGBA{0xa7, 0xc5, 0xff, 0x55})
|
||||
state.blob3.SetMinSize(fyne.NewSize(160, 48))
|
||||
state.blob3.Move(fyne.NewPos(180, 430))
|
||||
|
||||
w.SetOnDropped(func(_ fyne.Position, uris []fyne.URI) {
|
||||
var items []string
|
||||
for _, u := range uris {
|
||||
if u.Scheme() == "file" {
|
||||
items = append(items, u.Path())
|
||||
}
|
||||
}
|
||||
state.addPaths(items, w)
|
||||
})
|
||||
|
||||
chooseOutput := widget.NewButton("...", func() {
|
||||
save := dialog.NewFileSave(func(wc fyne.URIWriteCloser, err error) {
|
||||
if err != nil || wc == nil {
|
||||
return
|
||||
}
|
||||
state.output.SetText(wc.URI().Path())
|
||||
wc.Close()
|
||||
}, w)
|
||||
if path := strings.TrimSpace(state.output.Text); path != "" {
|
||||
save.SetFileName(filepath.Base(path))
|
||||
} else {
|
||||
save.SetFileName("images.pdf")
|
||||
}
|
||||
save.Show()
|
||||
})
|
||||
|
||||
state.convertBtn = widget.NewButton("Convert", func() {
|
||||
state.convert(w)
|
||||
})
|
||||
state.convertBtn.Importance = widget.MediumImportance
|
||||
|
||||
outputBar := makeOutputBar(state, state.output, chooseOutput)
|
||||
mainCard := makeCard(state, outputBar, w)
|
||||
|
||||
state.applyPalette(false)
|
||||
|
||||
toggle := widget.NewButton("☾", nil)
|
||||
toggle.OnTapped = func() {
|
||||
icon := state.toggleTheme()
|
||||
toggle.SetText(icon)
|
||||
}
|
||||
toggle.Importance = widget.LowImportance
|
||||
|
||||
closeBtn := widget.NewButton("×", func() { w.Close() })
|
||||
closeBtn.Importance = widget.LowImportance
|
||||
|
||||
content := container.NewBorder(
|
||||
container.NewHBox(closeBtn, layout.NewSpacer(), toggle),
|
||||
nil, nil, nil,
|
||||
container.NewMax(
|
||||
state.bg,
|
||||
container.NewWithoutLayout(state.blob1, state.blob2, state.blob3),
|
||||
container.NewCenter(mainCard),
|
||||
),
|
||||
)
|
||||
|
||||
w.SetContent(content)
|
||||
w.ShowAndRun()
|
||||
}
|
||||
|
||||
func makeCard(state *uiState, outputBar fyne.CanvasObject, win fyne.Window) fyne.CanvasObject {
|
||||
card := canvas.NewRectangle(color.NRGBA{0x00, 0x00, 0x00, 0x00})
|
||||
card.StrokeWidth = 2
|
||||
card.CornerRadius = 10
|
||||
card.SetMinSize(fyne.NewSize(580, 400))
|
||||
state.card = card
|
||||
|
||||
folder := makeFolderIcon(state, func() {
|
||||
open := dialog.NewFolderOpen(func(uri fyne.ListableURI, err error) {
|
||||
if err != nil || uri == nil {
|
||||
return
|
||||
}
|
||||
state.addPaths([]string{uri.Path()}, win)
|
||||
}, win)
|
||||
open.Show()
|
||||
})
|
||||
|
||||
tag := canvas.NewText("drop images or folders", color.NRGBA{0x1f, 0x1f, 0x1f, 0xff})
|
||||
tag.TextSize = 16
|
||||
tag.TextStyle = fyne.TextStyle{Monospace: true}
|
||||
tag.Alignment = fyne.TextAlignCenter
|
||||
state.tag = tag
|
||||
|
||||
convertWrap := container.NewMax()
|
||||
state.convertBg = canvas.NewRectangle(color.NRGBA{0xf0, 0xee, 0xea, 0xff})
|
||||
state.convertBg.CornerRadius = 6
|
||||
state.convertBg.StrokeWidth = 2
|
||||
state.convertBg.SetMinSize(fyne.NewSize(200, 46))
|
||||
convertWrap.Add(state.convertBg)
|
||||
convertWrap.Add(container.NewCenter(container.NewVBox(state.convertBtn, state.spinner)))
|
||||
|
||||
stack := container.NewVBox(
|
||||
container.NewCenter(folder),
|
||||
container.NewCenter(tag),
|
||||
layout.NewSpacer(),
|
||||
outputBar,
|
||||
container.NewCenter(convertWrap),
|
||||
container.NewCenter(state.status),
|
||||
)
|
||||
|
||||
cardContainer := container.NewMax(
|
||||
card,
|
||||
container.NewPadded(stack),
|
||||
)
|
||||
|
||||
return cardContainer
|
||||
}
|
||||
|
||||
func makeOutputBar(state *uiState, entry *widget.Entry, chooseOutput fyne.CanvasObject) fyne.CanvasObject {
|
||||
entry.SetPlaceHolder("out.pdf")
|
||||
entry.TextStyle = fyne.TextStyle{Monospace: true}
|
||||
entry.Refresh()
|
||||
|
||||
bg := canvas.NewRectangle(color.NRGBA{0xf0, 0xee, 0xea, 0xff})
|
||||
bg.StrokeWidth = 2
|
||||
bg.CornerRadius = 8
|
||||
bg.SetMinSize(fyne.NewSize(520, 46))
|
||||
state.outputBg = bg
|
||||
|
||||
bar := container.NewBorder(nil, nil, nil, chooseOutput, entry)
|
||||
return container.NewMax(bg, container.NewPadded(bar))
|
||||
}
|
||||
|
||||
func makeFolderIcon(state *uiState, onTap func()) fyne.CanvasObject {
|
||||
body := canvas.NewRectangle(color.NRGBA{0xe3, 0xe0, 0xda, 0xff})
|
||||
body.CornerRadius = 8
|
||||
body.SetMinSize(fyne.NewSize(180, 110))
|
||||
state.folderBody = body
|
||||
|
||||
outline := canvas.NewRectangle(color.Transparent)
|
||||
outline.StrokeColor = color.NRGBA{0x28, 0x2a, 0x36, 0xff}
|
||||
outline.StrokeWidth = 2
|
||||
outline.CornerRadius = 10
|
||||
outline.SetMinSize(fyne.NewSize(180, 110))
|
||||
state.folderOutline = outline
|
||||
|
||||
label := canvas.NewText("folder", color.NRGBA{0x28, 0x2a, 0x36, 0xff})
|
||||
label.TextSize = 13
|
||||
label.TextStyle = fyne.TextStyle{Monospace: true}
|
||||
label.Alignment = fyne.TextAlignCenter
|
||||
state.folderLabel = label
|
||||
|
||||
icon := container.NewStack(body, outline, container.NewCenter(label))
|
||||
|
||||
btn := widget.NewButton("", func() {
|
||||
if onTap != nil {
|
||||
onTap()
|
||||
}
|
||||
})
|
||||
btn.Importance = widget.LowImportance
|
||||
btn.SetIcon(nil)
|
||||
btn.SetText("")
|
||||
|
||||
return container.NewStack(icon, btn)
|
||||
}
|
||||
|
||||
type uiState struct {
|
||||
paths []string
|
||||
output *widget.Entry
|
||||
status *widget.Label
|
||||
mu sync.Mutex
|
||||
autoConvert bool
|
||||
convertBtn *widget.Button
|
||||
convertBg *canvas.Rectangle
|
||||
spinner *widget.ProgressBarInfinite
|
||||
busy bool
|
||||
dark bool
|
||||
palette palette
|
||||
outputBg *canvas.Rectangle
|
||||
bg *canvas.Rectangle
|
||||
blob1 *canvas.Rectangle
|
||||
blob2 *canvas.Rectangle
|
||||
blob3 *canvas.Rectangle
|
||||
card *canvas.Rectangle
|
||||
folderBody *canvas.Rectangle
|
||||
folderOutline *canvas.Rectangle
|
||||
folderLabel *canvas.Text
|
||||
tag *canvas.Text
|
||||
}
|
||||
|
||||
type palette struct {
|
||||
bg color.NRGBA
|
||||
accent1 color.NRGBA
|
||||
accent2 color.NRGBA
|
||||
accent3 color.NRGBA
|
||||
frame color.NRGBA
|
||||
surface color.NRGBA
|
||||
text color.NRGBA
|
||||
}
|
||||
|
||||
func (s *uiState) paletteFor(dark bool) palette {
|
||||
if dark {
|
||||
return palette{
|
||||
bg: color.NRGBA{0x1c, 0x1b, 0x1f, 0xff},
|
||||
accent1: color.NRGBA{0xff, 0x9f, 0x6e, 0x66},
|
||||
accent2: color.NRGBA{0x61, 0xb5, 0x9a, 0x66},
|
||||
accent3: color.NRGBA{0x88, 0xa6, 0xf2, 0x66},
|
||||
frame: color.NRGBA{0xee, 0xee, 0xee, 0xff},
|
||||
surface: color.NRGBA{0x2a, 0x29, 0x30, 0xff},
|
||||
text: color.NRGBA{0xee, 0xee, 0xee, 0xff},
|
||||
}
|
||||
}
|
||||
return palette{
|
||||
bg: color.NRGBA{0xf4, 0xf1, 0xec, 0xff},
|
||||
accent1: color.NRGBA{0xff, 0xc7, 0x8f, 0x55},
|
||||
accent2: color.NRGBA{0x8c, 0xd8, 0xc0, 0x55},
|
||||
accent3: color.NRGBA{0xa7, 0xc5, 0xff, 0x55},
|
||||
frame: color.NRGBA{0x28, 0x2a, 0x36, 0xff},
|
||||
surface: color.NRGBA{0xe3, 0xe0, 0xda, 0xff},
|
||||
text: color.NRGBA{0x1f, 0x1f, 0x1f, 0xff},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *uiState) applyPalette(dark bool) {
|
||||
p := s.paletteFor(dark)
|
||||
s.palette = p
|
||||
if s.bg != nil {
|
||||
s.bg.FillColor = p.bg
|
||||
}
|
||||
if s.blob1 != nil {
|
||||
s.blob1.FillColor = p.accent1
|
||||
}
|
||||
if s.blob2 != nil {
|
||||
s.blob2.FillColor = p.accent2
|
||||
}
|
||||
if s.blob3 != nil {
|
||||
s.blob3.FillColor = p.accent3
|
||||
}
|
||||
if s.card != nil {
|
||||
s.card.StrokeColor = p.frame
|
||||
}
|
||||
if s.folderBody != nil {
|
||||
s.folderBody.FillColor = p.surface
|
||||
}
|
||||
if s.folderOutline != nil {
|
||||
s.folderOutline.StrokeColor = p.frame
|
||||
}
|
||||
if s.folderLabel != nil {
|
||||
s.folderLabel.Color = p.text
|
||||
}
|
||||
if s.tag != nil {
|
||||
s.tag.Color = p.text
|
||||
}
|
||||
if s.outputBg != nil {
|
||||
s.outputBg.FillColor = p.surface
|
||||
s.outputBg.StrokeColor = p.frame
|
||||
}
|
||||
if s.convertBg != nil {
|
||||
s.convertBg.FillColor = p.accent2
|
||||
s.convertBg.StrokeColor = p.frame
|
||||
}
|
||||
}
|
||||
|
||||
func (s *uiState) toggleTheme() string {
|
||||
s.dark = !s.dark
|
||||
s.applyPalette(s.dark)
|
||||
if s.dark {
|
||||
return "☀"
|
||||
}
|
||||
return "☾"
|
||||
}
|
||||
|
||||
func (s *uiState) addPaths(inputs []string, win fyne.Window) {
|
||||
files, err := input.Collect(inputs)
|
||||
if err != nil {
|
||||
dialog.ShowError(err, win)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
seen := map[string]bool{}
|
||||
for _, p := range s.paths {
|
||||
seen[p] = true
|
||||
}
|
||||
for _, f := range files {
|
||||
if !seen[f] {
|
||||
s.paths = append(s.paths, f)
|
||||
}
|
||||
}
|
||||
s.sortPaths()
|
||||
if out := s.output.Text; strings.TrimSpace(out) == "" || out == "images.pdf" {
|
||||
if def, err := input.DefaultOutput(s.paths[0]); err == nil {
|
||||
s.output.SetText(def)
|
||||
}
|
||||
}
|
||||
s.refresh()
|
||||
if s.autoConvert {
|
||||
s.runWithSpinner(win, true)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *uiState) setPaths(paths []string) {
|
||||
s.mu.Lock()
|
||||
s.paths = paths
|
||||
s.mu.Unlock()
|
||||
s.refresh()
|
||||
}
|
||||
|
||||
func (s *uiState) sortPaths() {
|
||||
sort.Slice(s.paths, func(i, j int) bool {
|
||||
return convert.NaturalLess(filepath.Base(s.paths[i]), filepath.Base(s.paths[j]))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *uiState) refresh() {
|
||||
if len(s.paths) == 0 {
|
||||
s.status.SetText("Drop images or folders to begin.")
|
||||
} else {
|
||||
s.status.SetText(fmt.Sprintf("%d items queued", len(s.paths)))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *uiState) convert(win fyne.Window) {
|
||||
s.runWithSpinner(win, false)
|
||||
}
|
||||
|
||||
func (s *uiState) convertInternal(win fyne.Window, auto bool) {
|
||||
s.mu.Lock()
|
||||
paths := append([]string(nil), s.paths...)
|
||||
out := strings.TrimSpace(s.output.Text)
|
||||
s.mu.Unlock()
|
||||
|
||||
if len(paths) == 0 {
|
||||
if !auto {
|
||||
dialog.ShowInformation("img2pdf", "No images to convert.", win)
|
||||
}
|
||||
return
|
||||
}
|
||||
if out == "" {
|
||||
if def, err := input.DefaultOutput(paths[0]); err == nil {
|
||||
out = def
|
||||
} else {
|
||||
out = "images.pdf"
|
||||
}
|
||||
s.output.SetText(out)
|
||||
}
|
||||
|
||||
files, err := input.ReadSources(paths)
|
||||
if err != nil {
|
||||
dialog.ShowError(err, win)
|
||||
return
|
||||
}
|
||||
|
||||
s.status.SetText("Generating PDF...")
|
||||
pdf, err := convert.ToPDF(files)
|
||||
if err != nil {
|
||||
dialog.ShowError(err, win)
|
||||
s.refresh()
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(out, pdf, 0644); err != nil {
|
||||
dialog.ShowError(fmt.Errorf("write pdf: %w", err), win)
|
||||
return
|
||||
}
|
||||
s.setPaths(nil)
|
||||
s.output.SetText("images.pdf")
|
||||
s.status.SetText(fmt.Sprintf("Saved %s", out))
|
||||
if !auto {
|
||||
dialog.ShowInformation("img2pdf", fmt.Sprintf("Saved %s", out), win)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *uiState) runWithSpinner(win fyne.Window, auto bool) {
|
||||
s.mu.Lock()
|
||||
if s.busy {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.busy = true
|
||||
s.mu.Unlock()
|
||||
|
||||
s.spinner.Show()
|
||||
if s.convertBtn != nil {
|
||||
s.convertBtn.SetText("Converting…")
|
||||
s.convertBtn.Disable()
|
||||
}
|
||||
if s.convertBg != nil {
|
||||
s.convertBg.FillColor = s.palette.accent1
|
||||
}
|
||||
s.convertInternal(win, auto)
|
||||
s.spinner.Hide()
|
||||
if s.convertBg != nil {
|
||||
s.convertBg.FillColor = s.palette.surface
|
||||
}
|
||||
if s.convertBtn != nil {
|
||||
s.convertBtn.SetText("Convert")
|
||||
s.convertBtn.Enable()
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.busy = false
|
||||
s.mu.Unlock()
|
||||
}
|
||||
31
scripts/build.sh
Executable file
31
scripts/build.sh
Executable file
|
|
@ -0,0 +1,31 @@
|
|||
#!/usr/bin/env sh
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd -- "$(dirname "$0")/.." && pwd)"
|
||||
BIN_DIR="$ROOT/bin"
|
||||
BIN="$BIN_DIR/img2pdf"
|
||||
GOFLAGS_DEFAULT="-tags=wayland"
|
||||
CGO_CFLAGS_DEFAULT="-D_GNU_SOURCE"
|
||||
|
||||
# Keep caches inside the repo to avoid $HOME permission issues.
|
||||
export GOCACHE="${GOCACHE:-$ROOT/.cache/go-build}"
|
||||
export GOMODCACHE="${GOMODCACHE:-$ROOT/.cache/go-mod}"
|
||||
export GOFLAGS="${GOFLAGS:-$GOFLAGS_DEFAULT}"
|
||||
export CGO_CFLAGS="${CGO_CFLAGS:-$CGO_CFLAGS_DEFAULT}"
|
||||
export CGO_CXXFLAGS="${CGO_CXXFLAGS:-$CGO_CFLAGS}"
|
||||
mkdir -p "$GOCACHE" "$GOMODCACHE" "$BIN_DIR"
|
||||
|
||||
patch_glfw_wayland() {
|
||||
# Drop the redundant _GNU_SOURCE define in GLFW's Wayland file to avoid macro redefinition warnings.
|
||||
local wl_file="$GOMODCACHE/github.com/go-gl/glfw/v3.3/glfw@v0.0.0-20221017161538-93cebf72946b/glfw/src/wl_window.c"
|
||||
if [ -f "$wl_file" ] && grep -q '^#define _GNU_SOURCE$' "$wl_file"; then
|
||||
chmod u+w "$wl_file"
|
||||
tmp="$(mktemp)"
|
||||
sed '/^#define _GNU_SOURCE$/d' "$wl_file" >"$tmp" && mv "$tmp" "$wl_file"
|
||||
fi
|
||||
}
|
||||
|
||||
cd "$ROOT"
|
||||
patch_glfw_wayland
|
||||
go build -o "$BIN" ./cmd/img2pdf
|
||||
echo "Built: $BIN"
|
||||
73
scripts/install.sh
Executable file
73
scripts/install.sh
Executable file
|
|
@ -0,0 +1,73 @@
|
|||
#!/usr/bin/env sh
|
||||
# One-time installer: builds the binary and ensures it's on your PATH via ~/.local/bin.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd -- "$(dirname "$0")/.." && pwd)"
|
||||
BIN_DIR="$ROOT/bin"
|
||||
BIN="$BIN_DIR/img2pdf"
|
||||
GOFLAGS_DEFAULT="-tags=wayland"
|
||||
CGO_CFLAGS_DEFAULT="-D_GNU_SOURCE"
|
||||
|
||||
# Simple spinner for long-running steps (cycles 10 dots inline).
|
||||
run_with_spinner() {
|
||||
label="$1"; shift
|
||||
printf "%s" "$label"
|
||||
(
|
||||
count=0
|
||||
while :; do
|
||||
printf "."
|
||||
count=$(( (count + 1) % 10 ))
|
||||
if [ "$count" -eq 0 ]; then
|
||||
printf "\r%s" "$label"
|
||||
fi
|
||||
sleep 0.35
|
||||
done
|
||||
) &
|
||||
spid=$!
|
||||
"$@"; status=$?
|
||||
kill "$spid" 2>/dev/null
|
||||
printf "\n"
|
||||
return $status
|
||||
}
|
||||
|
||||
export GOCACHE="${GOCACHE:-$ROOT/.cache/go-build}"
|
||||
export GOMODCACHE="${GOMODCACHE:-$ROOT/.cache/go-mod}"
|
||||
export GOFLAGS="${GOFLAGS:-$GOFLAGS_DEFAULT}"
|
||||
export CGO_CFLAGS="${CGO_CFLAGS:-$CGO_CFLAGS_DEFAULT}"
|
||||
export CGO_CXXFLAGS="${CGO_CXXFLAGS:-$CGO_CFLAGS}"
|
||||
mkdir -p "$GOCACHE" "$GOMODCACHE" "$BIN_DIR"
|
||||
|
||||
patch_glfw_wayland() {
|
||||
# Drop the redundant _GNU_SOURCE define in GLFW's Wayland file to avoid macro redefinition warnings.
|
||||
local wl_file="$GOMODCACHE/github.com/go-gl/glfw/v3.3/glfw@v0.0.0-20221017161538-93cebf72946b/glfw/src/wl_window.c"
|
||||
if [ -f "$wl_file" ] && grep -q '^#define _GNU_SOURCE$' "$wl_file"; then
|
||||
chmod u+w "$wl_file"
|
||||
tmp="$(mktemp)"
|
||||
sed '/^#define _GNU_SOURCE$/d' "$wl_file" >"$tmp" && mv "$tmp" "$wl_file"
|
||||
fi
|
||||
}
|
||||
|
||||
# Ensure dependencies are present (generates go.sum). If this fails, likely due to blocked network.
|
||||
ensure_deps() {
|
||||
if [ ! -f "$ROOT/go.sum" ]; then
|
||||
run_with_spinner "Resolving dependencies" env GOCACHE="$GOCACHE" GOMODCACHE="$GOMODCACHE" go mod tidy || {
|
||||
echo "Failed to resolve dependencies (network or permissions issue). Please run:"
|
||||
echo " GOMODCACHE=\"$GOMODCACHE\" GOCACHE=\"$GOCACHE\" go mod tidy"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
# Prefetch modules; if it fails (offline), warn but continue to build from cache.
|
||||
if ! run_with_spinner "Downloading modules" env GOCACHE="$GOCACHE" GOMODCACHE="$GOMODCACHE" go mod download 2>/dev/null; then
|
||||
echo "Warning: could not download modules (maybe offline). Continuing with existing cache."
|
||||
fi
|
||||
}
|
||||
|
||||
cd "$ROOT"
|
||||
ensure_deps
|
||||
patch_glfw_wayland
|
||||
if ! run_with_spinner "Building img2pdf" env GOCACHE="$GOCACHE" GOMODCACHE="$GOMODCACHE" GOFLAGS="$GOFLAGS" go build -o "$BIN" ./cmd/img2pdf; then
|
||||
echo "Build failed. See errors above."
|
||||
exit 1
|
||||
fi
|
||||
echo "Built: $BIN"
|
||||
echo "Use: ./img2pdf (from repo root)"
|
||||
22
scripts/run.sh
Executable file
22
scripts/run.sh
Executable file
|
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/env sh
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd -- "$(dirname "$0")/.." && pwd)"
|
||||
BIN="$ROOT/bin/img2pdf"
|
||||
|
||||
# Ensure caches stay local if build is needed.
|
||||
export GOCACHE="${GOCACHE:-$ROOT/.cache/go-build}"
|
||||
export GOMODCACHE="${GOMODCACHE:-$ROOT/.cache/go-mod}"
|
||||
|
||||
if [ ! -x "$BIN" ]; then
|
||||
echo "Binary not found; building..."
|
||||
"$ROOT/scripts/build.sh"
|
||||
fi
|
||||
|
||||
# No args: launch UI (Wayland only).
|
||||
if [ "$#" -eq 0 ]; then
|
||||
export FYNE_DRIVER="${FYNE_DRIVER:-wayland}"
|
||||
"$BIN"
|
||||
else
|
||||
"$BIN" "$@"
|
||||
fi
|
||||
Loading…
Reference in New Issue
Block a user