diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 42e0adc..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Stu Leak / Leak Technologies - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f281420 --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +.PHONY: all build run install clean + +all: build + +build: + @echo "Building img2pdf..." + @mkdir -p bin + @CGO_ENABLED=1 go build -o bin/img2pdf main.go + @echo "Built successfully to bin/img2pdf" + +run: build + @echo "Running img2pdf..." + @./bin/img2pdf + +install: + @echo "Installing dependencies..." + @./scripts/install.sh + +clean: + @echo "Cleaning up..." + @rm -rf bin/ + +cgo-disabled: + @echo "Building without CGO..." + @mkdir -p bin + @CGO_ENABLED=0 go build -o bin/img2pdf main.go + @echo "Built with CGO disabled (limited functionality)" \ No newline at end of file diff --git a/PKGBUILD b/PKGBUILD deleted file mode 100644 index 4d8ba39..0000000 --- a/PKGBUILD +++ /dev/null @@ -1,30 +0,0 @@ -# Maintainer: Stu Leak - -pkgname=img2pdf -pkgver=1.0.3 -pkgrel=1 -pkgdesc="Convert a single image or a folder of images to a PDF (sorted by filename)." -arch=('any') -url="https://git.leaktechnologies.dev/stu/img2pdf" -license=('MIT') -depends=('python' 'python-pillow') -makedepends=('python-build' 'python-installer' 'python-setuptools' 'python-wheel') - -# Gitea tag archive (works after you push tag v$pkgver) -source=("$pkgname-$pkgver.tar.gz::https://git.leaktechnologies.dev/stu/img2pdf/archive/v$pkgver.tar.gz") -sha256sums=('SKIP') - -build() { - cd "$(find "$srcdir" -maxdepth 1 -type d -name "${pkgname}*" | head -n 1)" - python -m build --wheel --no-isolation -} - -package() { - cd "$(find "$srcdir" -maxdepth 1 -type d -name "${pkgname}*" | head -n 1)" - python -m installer --destdir="$pkgdir" dist/*.whl -} - - -# Usage: -# makepkg -si -# img2pdf ./images -o output.pdf diff --git a/README.md b/README.md index dc36ae0..c700873 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,79 @@ -# Leak Technologies — img2pdf +# img2pdf -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. +A minimalist Go application for batch converting images to PDF, designed to reproduce the exact mockup specification. -## Features -- 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) +## Setup + +### Prerequisites + +1. **Go** (1.19 or later) +2. **GUI development libraries** (required for Fyne) + +#### Linux (Fedora/CentOS/RHEL) +```bash +sudo dnf install libX11-devel libXcursor-devel libXrandr-devel libXi-devel mesa-libGL-devel libXinerama-devel +``` + +#### Linux (Ubuntu/Debian) +```bash +sudo apt-get install libgl1-mesa-dev libx11-dev libxcursor-dev libxrandr-dev libxinerama-dev libxi-dev +``` + +#### macOS +```bash +xcode-select --install +``` + +#### Windows +Install MinGW-w64 or TDM-GCC for development headers. + +### Installation + +```bash +# Clone and setup +git clone +cd img2pdf + +# Install dependencies +./scripts/install.sh + +# Build the application +./scripts/build.sh + +# Or use Makefile +make build +``` ## Usage -Install/build (from repo root): + ```bash -./install.sh # or ./scripts/build.sh +# Run the application +./scripts/run.sh + +# Or using Makefile +make run ``` -Run UI (default when no args): -```bash -./img2pdf -``` +## Design -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. +This application follows a strict late-80s/early-90s IBM utility aesthetic: -## 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). +- Fixed window size: 420×380 +- IBM beige color palette +- No animations, gradients, or shadows +- Flat, functional interface +- Explicit visual hierarchy -## 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. +## Scripts -## License -MIT +- `./scripts/install.sh` - Install all dependencies +- `./scripts/build.sh` - Build the application +- `./scripts/run.sh` - Run the application +- `make` - Alternative build system with targets: build, run, install, clean + +## Color Palette + +- Cream Main: `#E6E1D6` +- Cream Inset: `#E3DDCF` +- Ink Primary: `#1E1F22` +- Ink Soft: `#2A2B2F` \ No newline at end of file diff --git a/cmd/img2pdf/main.go b/cmd/img2pdf/main.go deleted file mode 100644 index 95d2c23..0000000 --- a/cmd/img2pdf/main.go +++ /dev/null @@ -1,95 +0,0 @@ -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/core/__init__.py b/core/__init__.py deleted file mode 100644 index e132bb2..0000000 --- a/core/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -""" -img2pdf - Leak Technologies -Convert a single image or folder of images into a PDF file (sorted by filename). -""" -__version__ = "1.0.3" -__author__ = "Stu Leak " diff --git a/core/__main__.py b/core/__main__.py deleted file mode 100644 index e69de29..0000000 diff --git a/core/config.py b/core/config.py deleted file mode 100644 index f4b1509..0000000 --- a/core/config.py +++ /dev/null @@ -1,24 +0,0 @@ -import argparse -from pathlib import Path - -def parse_arguments(): - parser = argparse.ArgumentParser( - description="Convert an image or folder of images into a PDF file." - ) - parser.add_argument( - "input_path", - type=str, - help="Path to an image file or a folder containing images.", - ) - parser.add_argument( - "-o", "--output", - type=str, - required=True, - help="Output PDF file path.", - ) - parser.add_argument( - "--overwrite", - action="store_true", - help="Overwrite the output file if it already exists.", - ) - return parser.parse_args() diff --git a/core/converter.py b/core/converter.py deleted file mode 100644 index e69de29..0000000 diff --git a/core/utils.py b/core/utils.py deleted file mode 100644 index e69de29..0000000 diff --git a/go.mod b/go.mod index 1f9f793..a6a8bf1 100644 --- a/go.mod +++ b/go.mod @@ -1,35 +1,43 @@ module img2pdf -go 1.21 - -require fyne.io/fyne/v2 v2.4.4 +go 1.22 require ( - fyne.io/systray v1.10.1-0.20231115130155-104f5ef7839e // indirect + fyne.io/fyne/v2 v2.7.1 + github.com/jung-kurt/gofpdf v1.16.0 +) + +require ( + fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58 // indirect + github.com/BurntSushi/toml v1.5.0 // 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/fredbi/uri v1.1.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fyne-io/gl-js v0.2.0 // indirect + github.com/fyne-io/glfw-js v0.3.0 // indirect + github.com/fyne-io/image v0.1.1 // indirect + github.com/fyne-io/oksvg v0.2.0 // indirect + github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect + github.com/go-text/render v0.2.0 // indirect + github.com/go-text/typesetting v0.2.1 // 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/hack-pad/go-indexeddb v0.3.2 // indirect + github.com/hack-pad/safejs v0.1.0 // indirect + github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect + github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rymdport/portal v0.4.2 // 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 + github.com/stretchr/testify v1.11.1 // indirect + github.com/yuin/goldmark v1.7.8 // indirect + golang.org/x/image v0.24.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.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 deleted file mode 100755 index ef283db..0000000 --- a/img2pdf +++ /dev/null @@ -1,11 +0,0 @@ -#!/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 deleted file mode 100755 index db8d1c7..0000000 --- a/install.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/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 deleted file mode 100644 index e491ece..0000000 --- a/internal/convert/convert.go +++ /dev/null @@ -1,219 +0,0 @@ -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 deleted file mode 100644 index d835758..0000000 --- a/internal/input/input.go +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index 69068d4..0000000 --- a/internal/ui/ui.go +++ /dev/null @@ -1,442 +0,0 @@ -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/main.go b/main.go new file mode 100644 index 0000000..18e3e04 --- /dev/null +++ b/main.go @@ -0,0 +1,272 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "image" + "image/color" + _ "image/gif" + _ "image/jpeg" + "image/png" + "os" + "path/filepath" + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/storage" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/jung-kurt/gofpdf" +) + +var ( + CreamMain = color.NRGBA{0xE6, 0xE1, 0xD6, 0xFF} + CreamInset = color.NRGBA{0xE3, 0xDD, 0xCF, 0xFF} + InkPrimary = color.NRGBA{0x1E, 0x1F, 0x22, 0xFF} + InkSoft = color.NRGBA{0x2A, 0x2B, 0x2F, 0xFF} +) + +const dropPrompt = "Drag and drop files to\nbatch convert to PDF." + +func main() { + a := app.New() + w := a.NewWindow("img2pdf") + + w.Resize(fyne.NewSize(420, 380)) + w.SetFixedSize(true) + + // Root background + bg := canvas.NewRectangle(CreamMain) + + // Drop area + dropBg := canvas.NewRectangle(CreamInset) + dropBg.CornerRadius = 10 + + dropBorder := canvas.NewRectangle(color.Transparent) + dropBorder.StrokeColor = InkPrimary + dropBorder.StrokeWidth = 2 + dropBorder.CornerRadius = 10 + + folderIcon := widget.NewIcon(theme.FolderIcon()) + + dropText := canvas.NewText(dropPrompt, InkPrimary) + dropText.TextSize = 11 + dropText.Alignment = fyne.TextAlignCenter + + dropContent := container.NewVBox( + folderIcon, + dropText, + ) + + dropArea := container.NewStack( + dropBorder, + container.NewPadded( + dropBg, + container.NewCenter(dropContent), + ), + ) + + // Output bar + pathBg := canvas.NewRectangle(CreamInset) + pathBg.CornerRadius = 5 + pathEntry := widget.NewEntry() + pathEntry.SetText(defaultOutputPath()) + + browseBtn := widget.NewButton("Browse", func() { + saveDialog := dialog.NewFileSave(func(uc fyne.URIWriteCloser, err error) { + if err != nil { + dialog.ShowError(err, w) + return + } + if uc == nil { + return + } + path := uc.URI().Path() + _ = uc.Close() + pathEntry.SetText(ensurePDFExtension(path)) + }, w) + saveDialog.SetFileName(filepath.Base(pathEntry.Text)) + saveDialog.SetFilter(storage.NewExtensionFileFilter([]string{".pdf"})) + saveDialog.Show() + }) + browseBtn.Importance = widget.LowImportance + + pathBar := container.NewStack( + pathBg, + container.NewPadded( + container.NewBorder(nil, nil, nil, browseBtn, pathEntry), + ), + ) + + // Layout stack + content := container.NewVBox( + layout.NewSpacer(), + dropArea, + layout.NewSpacer(), + pathBar, + ) + + padded := container.NewPadded(content) + root := container.NewStack(bg, padded) + + w.SetContent(root) + + updateStatus := func(text string) { + fyne.CurrentApp().Driver().RunOnMain(func() { + dropText.Text = text + dropText.Refresh() + }) + } + + w.SetOnDropped(func(_ fyne.Position, uris []fyne.URI) { + paths := uriPaths(uris) + if len(paths) == 0 { + dialog.ShowInformation("No Files", "Drop one or more image files.", w) + return + } + + outputPath := expandUser(pathEntry.Text) + if outputPath == "" { + outputPath = defaultOutputPath() + pathEntry.SetText(outputPath) + } + + updateStatus("Converting images...") + go func() { + err := convertImagesToPDF(paths, outputPath) + if err != nil { + updateStatus(dropPrompt) + fyne.CurrentApp().Driver().RunOnMain(func() { + dialog.ShowError(err, w) + }) + return + } + updateStatus(dropPrompt) + fyne.CurrentApp().Driver().RunOnMain(func() { + dialog.ShowInformation("Done", fmt.Sprintf("Saved PDF to:\n%s", outputPath), w) + }) + }() + }) + + w.ShowAndRun() +} + +func defaultOutputPath() string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "magazine.pdf" + } + return filepath.Join(home, "Documents", "img2pdf", "magazine.pdf") +} + +func ensurePDFExtension(path string) string { + if strings.EqualFold(filepath.Ext(path), ".pdf") { + return path + } + return path + ".pdf" +} + +func expandUser(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + if path == "~" { + home, err := os.UserHomeDir() + if err == nil { + return home + } + return path + } + if strings.HasPrefix(path, "~"+string(os.PathSeparator)) { + home, err := os.UserHomeDir() + if err == nil { + return filepath.Join(home, path[2:]) + } + } + return path +} + +func uriPaths(uris []fyne.URI) []string { + paths := make([]string, 0, len(uris)) + for _, uri := range uris { + if uri == nil || uri.Scheme() != "file" { + continue + } + path := uri.Path() + if path == "" { + continue + } + paths = append(paths, path) + } + return paths +} + +func convertImagesToPDF(paths []string, outputPath string) error { + if len(paths) == 0 { + return errors.New("no image files provided") + } + outputPath = ensurePDFExtension(expandUser(outputPath)) + if outputPath == "" { + return errors.New("output path is empty") + } + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + return fmt.Errorf("create output folder: %w", err) + } + + pdf := gofpdf.New("P", "pt", "A4", "") + pdf.SetMargins(0, 0, 0) + pdf.SetAutoPageBreak(false, 0) + + for i, path := range paths { + img, err := loadImage(path) + if err != nil { + return fmt.Errorf("decode %s: %w", filepath.Base(path), err) + } + + bounds := img.Bounds() + if bounds.Dx() == 0 || bounds.Dy() == 0 { + return fmt.Errorf("image has no size: %s", filepath.Base(path)) + } + + buf := &bytes.Buffer{} + if err := png.Encode(buf, img); err != nil { + return fmt.Errorf("encode %s: %w", filepath.Base(path), err) + } + + name := fmt.Sprintf("img-%d", i) + opts := gofpdf.ImageOptions{ImageType: "PNG", ReadDpi: true} + if _, err := pdf.RegisterImageOptionsReader(name, opts, buf); err != nil { + return fmt.Errorf("register %s: %w", filepath.Base(path), err) + } + + pageSize := gofpdf.SizeType{Wd: float64(bounds.Dx()), Ht: float64(bounds.Dy())} + pdf.AddPageFormat("P", pageSize) + pdf.ImageOptions(name, 0, 0, pageSize.Wd, pageSize.Ht, false, opts, 0, "") + } + + if err := pdf.OutputFileAndClose(outputPath); err != nil { + return fmt.Errorf("write pdf: %w", err) + } + return nil +} + +func loadImage(path string) (image.Image, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + img, _, err := image.Decode(file) + if err != nil { + return nil, err + } + return img, nil +} diff --git a/mockup/mockup.png b/mockup/mockup.png new file mode 100644 index 0000000..88b653e Binary files /dev/null and b/mockup/mockup.png differ diff --git a/mockup/mockup.svg b/mockup/mockup.svg new file mode 100644 index 0000000..67950e2 --- /dev/null +++ b/mockup/mockup.svg @@ -0,0 +1,112 @@ + + + +BrowseDrag and drop files tobatch convert to PDF.~/Documents/img2pdf/magazine.pdf diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index d50d44e..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,23 +0,0 @@ -[build-system] -requires = ["setuptools", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "img2pdf" -version = "1.0.4" -description = "Convert a single image or folder of images into a PDF file (sorted by filename)." -readme = "README.md" -requires-python = ">=3.8" -license = "MIT" -authors = [{ name = "Stu Leak", email = "leaktechnologies@proton.me" }] -dependencies = ["pillow>=10.0.0"] - -[project.urls] -Homepage = "https://git.leaktechnologies.dev/stu/img2pdf" -Issues = "https://git.leaktechnologies.dev/stu/img2pdf/issues" - -[project.scripts] -img2pdf = "core.__main__:main" - -[tool.setuptools] -packages = ["core"] diff --git a/scripts/build.sh b/scripts/build.sh index 327b7c1..424f0d2 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,31 +1,24 @@ -#!/usr/bin/env sh -set -euo pipefail +#!/bin/bash +echo "Building img2pdf..." -ROOT="$(cd -- "$(dirname "$0")/.." && pwd)" -BIN_DIR="$ROOT/bin" -BIN="$BIN_DIR/img2pdf" -GOFLAGS_DEFAULT="-tags=wayland" -CGO_CFLAGS_DEFAULT="-D_GNU_SOURCE" +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -# 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" +# Ensure Go modules are downloaded (override readonly modules if set) +(cd "$ROOT_DIR" && GOFLAGS=-mod=mod go mod download) -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 -} +# Create bin directory if it doesn't exist +mkdir -p "$ROOT_DIR/bin" -cd "$ROOT" -patch_glfw_wayland -go build -o "$BIN" ./cmd/img2pdf -echo "Built: $BIN" +# Try to build with a reasonable timeout +(cd "$ROOT_DIR" && GOFLAGS=-mod=mod timeout 60 go build -o bin/img2pdf main.go) + +if [ $? -eq 0 ]; then + echo "Built successfully to bin/img2pdf" +else + echo "Build failed or timed out. This may be due to OpenGL dependencies." + echo "Try installing development packages:" + echo " sudo dnf install mesa-libGL-devel libX11-devel libXcursor-devel" + echo "" + echo "Or try building with CGO disabled (limited functionality):" + echo " CGO_ENABLED=0 go build -o bin/img2pdf main.go" +fi diff --git a/scripts/install.sh b/scripts/install.sh index ead9eaf..2c034b6 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,73 +1,59 @@ -#!/usr/bin/env sh -# One-time installer: builds the binary and ensures it's on your PATH via ~/.local/bin. -set -euo pipefail +#!/bin/bash -ROOT="$(cd -- "$(dirname "$0")/.." && pwd)" -BIN_DIR="$ROOT/bin" -BIN="$BIN_DIR/img2pdf" -GOFLAGS_DEFAULT="-tags=wayland" -CGO_CFLAGS_DEFAULT="-D_GNU_SOURCE" +echo "Installing dependencies for img2pdf..." +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -# 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 +# Check if Go is installed +if ! command -v go &> /dev/null; then + echo "Go is not installed. Please install Go first:" + echo " - On Ubuntu/Debian: sudo apt install golang-go" + echo " - On macOS: brew install go" + echo " - On Windows: Download from https://golang.org/dl/" + exit 1 fi -echo "Built: $BIN" -echo "Use: ./img2pdf (from repo root)" + +# Check if we're in a git repository +if ! git rev-parse --git-dir > /dev/null 2>&1; then + echo "This is not a git repository. Initializing git..." + git init +fi + +# Install Go dependencies +echo "Installing Go dependencies..." +(cd "$ROOT_DIR" && go mod download) + +# Check for system-specific GUI dependencies +case "$(uname -s)" in + Linux*) + echo "Checking for Linux GUI dependencies..." + if command -v apt-get &> /dev/null; then + echo "Detected Debian/Ubuntu system" + echo "Installing required development packages..." + echo "Please enter your sudo password when prompted:" + sudo apt-get install -y libgl1-mesa-dev libx11-dev libxcursor-dev libxrandr-dev libxinerama-dev libxi-dev libglu1-mesa-dev + elif command -v dnf &> /dev/null; then + echo "Detected Fedora system" + echo "Installing required development packages..." + echo "Please enter your sudo password when prompted:" + sudo dnf install -y libX11-devel libXcursor-devel libXrandr-devel libXi-devel mesa-libGL-devel libXinerama-devel + elif command -v pacman &> /dev/null; then + echo "Detected Arch Linux system" + echo "Installing required development packages..." + echo "Please enter your sudo password when prompted:" + sudo pacman -S --noconfirm libx11 libxcursor libxrandr libxi mesa libxinerama + fi + ;; + Darwin*) + echo "macOS detected - GUI dependencies should be handled by Xcode command line tools" + if ! xcode-select -p &> /dev/null; then + echo "Installing Xcode command line tools..." + xcode-select --install + fi + ;; + CYGWIN*|MINGW*|MSYS*) + echo "Windows detected - make sure you have GCC/MinGW installed" + ;; +esac + +echo "Dependency installation complete!" +echo "Run './scripts/build.sh' to build the application." diff --git a/scripts/run.sh b/scripts/run.sh index 3c8b668..68b3777 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -1,22 +1,4 @@ -#!/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 +#!/bin/bash +echo "Running img2pdf..." +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +"$ROOT_DIR/bin/img2pdf"