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:
stu 2025-12-01 09:28:38 -05:00
parent 79b11c27ef
commit f00ca45f59
12 changed files with 1061 additions and 35 deletions

13
.gitignore vendored
View File

@ -1,10 +1,3 @@
# build artefacts /.cache/
pkg/ /bin/
src/ go.sum
*.egg-info/
*.tar.gz
*.pkg.tar.*
build/
dist/
__pycache__/
.venv/

View File

@ -1,32 +1,40 @@
# Leak Technologies — img2pdf # Leak Technologies — img2pdf
**Version:** 1.0.0 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.
**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.
---
## Features ## Features
- Drag-and-drop UI for images or folders; natural filename sorting
- Converts individual images or entire folders - Choose output location/name in the UI; defaults next to the first input
- Auto-sorts images (natural order) - Supported formats (stdlib decoders): JPG, PNG, GIF
- Supports JPG, PNG, BMP, TIFF, WEBP - Lightweight custom PDF writer (no third-party PDF libs)
- Clean, colorized CLI output
- Safe overwrite handling (`--overwrite`)
- Optional quiet mode for scripting (`--quiet`)
---
## Usage ## Usage
Install/build (from repo root):
```bash ```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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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