Simplify project structure and update build system

- Remove Python codebase and packaging files
- Consolidate Go application to single main.go file
- Add Makefile for build management
- Update README with new Go-only structure
- Remove unused dependencies and legacy scripts
This commit is contained in:
Leak Technologies 2026-01-09 03:34:58 -05:00
parent f00ca45f59
commit 465f90a8f8
23 changed files with 591 additions and 1140 deletions

21
LICENSE
View File

@ -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.

27
Makefile Normal file
View File

@ -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)"

View File

@ -1,30 +0,0 @@
# Maintainer: Stu Leak <leaktechnologies@proton.me>
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

View File

@ -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 <repository-url>
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] <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.
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`

View File

@ -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] <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))
}

View File

@ -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 <leaktechnologies@proton.me>"

View File

View File

@ -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()

View File

View File

56
go.mod
View File

@ -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
)

11
img2pdf
View File

@ -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" "$@"

View File

@ -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" "$@"

View File

@ -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)
}

View File

@ -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
}

View File

@ -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()
}

272
main.go Normal file
View File

@ -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
}

BIN
mockup/mockup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

112
mockup/mockup.svg Normal file
View File

@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="400"
height="300"
viewBox="0 0 105.83333 79.375"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
sodipodi:docname="mockup.svg"
xml:space="preserve"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="2.0157702"
inkscape:cx="166.68567"
inkscape:cy="129.97513"
inkscape:window-width="1920"
inkscape:window-height="1011"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"
showguides="false" /><defs
id="defs1" /><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"><rect
style="fill:#e6e1d6;fill-opacity:1;stroke:none;stroke-width:1.32292;stroke-opacity:1"
id="rect1"
width="105.83333"
height="79.375"
x="0"
y="0"
rx="6"
inkscape:label="BG"
sodipodi:insensitive="true" /><rect
style="fill:none;stroke:#1e1f22;stroke-width:0.79375;stroke-dasharray:none;stroke-opacity:1"
id="rect2"
width="91.657608"
height="51.295258"
x="7.0878625"
y="9.5846624"
rx="5" /><rect
style="fill:none;stroke:#1e1f22;stroke-width:0.79375;stroke-dasharray:none;stroke-opacity:1"
id="rect3"
width="91.657997"
height="6"
x="7.0878649"
y="63.790337"
rx="2" /><rect
style="fill:#1e1f22;stroke:none;stroke-width:0.79375;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
id="rect4"
width="16"
height="6"
x="82.745865"
y="63.790337"
rx="2" /><text
xml:space="preserve"
style="font-size:3.52778px;line-height:1.5;font-family:'IBM Plex Mono';-inkscape-font-specification:'IBM Plex Mono';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#e6e1d6;fill-opacity:1;stroke:none;stroke-width:0.79375;stroke-dasharray:none;stroke-opacity:1"
x="84.358818"
y="68.000366"
id="text4"><tspan
sodipodi:role="line"
id="tspan4"
style="font-size:3.52778px;fill:#e6e1d6;fill-opacity:1;stroke-width:0.79375"
x="84.358818"
y="68.000366">Browse</tspan></text><g
id="g7"
transform="translate(0,-5.155648)"><text
xml:space="preserve"
style="font-size:3.52777px;line-height:1.5;font-family:'IBM Plex Mono';-inkscape-font-specification:'IBM Plex Mono';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#1e1f22;fill-opacity:1;stroke:none;stroke-width:0.79375;stroke-dasharray:none;stroke-opacity:1"
x="29.582218"
y="42.326267"
id="text5"><tspan
sodipodi:role="line"
id="tspan5"
style="stroke-width:0.79375"
x="29.582218"
y="42.326267">Drag and drop files to</tspan><tspan
sodipodi:role="line"
style="stroke-width:0.79375"
x="29.582218"
y="47.617924"
id="tspan6">batch convert to PDF.</tspan></text><g
style="fill:#1e1f22;fill-opacity:1"
id="g6"
transform="matrix(0.00661458,0,0,0.00661458,49.543231,38.40729)"><path
d="m 160,-160 q -33,0 -56.5,-23.5 Q 80,-207 80,-240 v -480 q 0,-33 23.5,-56.5 Q 127,-800 160,-800 h 240 l 80,80 h 320 q 33,0 56.5,23.5 Q 880,-673 880,-640 H 447 l -80,-80 H 160 v 480 l 96,-320 h 684 l -103,343 q -8,26 -29.5,41.5 Q 786,-160 760,-160 Z m 84,-80 h 516 l 72,-240 H 316 Z m 0,0 72,-240 z m -84,-400 v -80 z"
id="path1"
style="fill:#1e1f22;fill-opacity:1" /></g></g><text
xml:space="preserve"
style="font-size:3.52777px;line-height:1.5;font-family:'IBM Plex Mono';-inkscape-font-specification:'IBM Plex Mono';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#1e1f22;fill-opacity:1;stroke:none;stroke-width:0.79375;stroke-dasharray:none;stroke-opacity:1"
x="8.6629429"
y="67.756943"
id="text7"><tspan
sodipodi:role="line"
id="tspan7"
style="stroke-width:0.79375"
x="8.6629429"
y="67.756943">~/Documents/img2pdf/magazine.pdf</tspan></text></g></svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@ -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"]

View File

@ -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

View File

@ -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."

View File

@ -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"