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:
parent
f00ca45f59
commit
465f90a8f8
21
LICENSE
21
LICENSE
|
|
@ -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
27
Makefile
Normal 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)"
|
||||
30
PKGBUILD
30
PKGBUILD
|
|
@ -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
|
||||
97
README.md
97
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 <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`
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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>"
|
||||
|
|
@ -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()
|
||||
56
go.mod
56
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
|
||||
)
|
||||
|
|
|
|||
11
img2pdf
11
img2pdf
|
|
@ -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" "$@"
|
||||
|
|
@ -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" "$@"
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
272
main.go
Normal 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
BIN
mockup/mockup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
112
mockup/mockup.svg
Normal file
112
mockup/mockup.svg
Normal 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 |
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user