From f00ca45f59fce39b9cca7dae9bbbd99c126337ad Mon Sep 17 00:00:00 2001 From: stu Date: Mon, 1 Dec 2025 09:28:38 -0500 Subject: [PATCH] Update .gitignore, README, and add build outputs and tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 13 +- README.md | 58 +++-- cmd/img2pdf/main.go | 95 ++++++++ go.mod | 35 +++ img2pdf | 11 + install.sh | 4 + internal/convert/convert.go | 219 ++++++++++++++++++ internal/input/input.go | 93 ++++++++ internal/ui/ui.go | 442 ++++++++++++++++++++++++++++++++++++ scripts/build.sh | 31 +++ scripts/install.sh | 73 ++++++ scripts/run.sh | 22 ++ 12 files changed, 1061 insertions(+), 35 deletions(-) create mode 100644 cmd/img2pdf/main.go create mode 100644 go.mod create mode 100755 img2pdf create mode 100755 install.sh create mode 100644 internal/convert/convert.go create mode 100644 internal/input/input.go create mode 100644 internal/ui/ui.go create mode 100755 scripts/build.sh create mode 100755 scripts/install.sh create mode 100755 scripts/run.sh diff --git a/.gitignore b/.gitignore index 7b0638a..f11e143 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,3 @@ -# build artefacts -pkg/ -src/ -*.egg-info/ -*.tar.gz -*.pkg.tar.* -build/ -dist/ -__pycache__/ -.venv/ +/.cache/ +/bin/ +go.sum diff --git a/README.md b/README.md index 10c92f0..dc36ae0 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,40 @@ # Leak Technologies — img2pdf -**Version:** 1.0.0 -**Author:** Stu Leak () -**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 [-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] [more_paths...] +``` +- If `-o` is omitted, the PDF is written next to the first input: for a folder, `/.pdf`; for a single 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 diff --git a/cmd/img2pdf/main.go b/cmd/img2pdf/main.go new file mode 100644 index 0000000..95d2c23 --- /dev/null +++ b/cmd/img2pdf/main.go @@ -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] [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)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1f9f793 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/img2pdf b/img2pdf new file mode 100755 index 0000000..ef283db --- /dev/null +++ b/img2pdf @@ -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" "$@" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..db8d1c7 --- /dev/null +++ b/install.sh @@ -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" "$@" diff --git a/internal/convert/convert.go b/internal/convert/convert.go new file mode 100644 index 0000000..e491ece --- /dev/null +++ b/internal/convert/convert.go @@ -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) +} diff --git a/internal/input/input.go b/internal/input/input.go new file mode 100644 index 0000000..d835758 --- /dev/null +++ b/internal/input/input.go @@ -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 +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..69068d4 --- /dev/null +++ b/internal/ui/ui.go @@ -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() +} diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..327b7c1 --- /dev/null +++ b/scripts/build.sh @@ -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" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..ead9eaf --- /dev/null +++ b/scripts/install.sh @@ -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)" diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 0000000..3c8b668 --- /dev/null +++ b/scripts/run.sh @@ -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