Initial Release
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"path"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
"golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
Name string
|
||||
Images []Image
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
Name string
|
||||
Data []byte
|
||||
MimeType string
|
||||
}
|
||||
|
||||
type QueuedItem struct {
|
||||
Basename string // Filename without Extension
|
||||
Filename string // Filename with Extension
|
||||
Nest []string // Subdirectories
|
||||
}
|
||||
|
||||
const (
|
||||
OUTPUT_DIR = "convert"
|
||||
OUTPUT_FLAG = 0755
|
||||
)
|
||||
|
||||
var (
|
||||
featureRecursive bool = false
|
||||
featureExtract bool = false
|
||||
featureHeight int = 800
|
||||
featureWidth int = 600
|
||||
featureQuality int = 25
|
||||
flags []string
|
||||
queue []QueuedItem
|
||||
)
|
||||
|
||||
//go:embed templates/*
|
||||
var templateFS embed.FS
|
||||
|
||||
func main() {
|
||||
t := time.Now()
|
||||
|
||||
// Parse Arguments
|
||||
for i := 1; i < len(os.Args); i++ {
|
||||
segments := strings.SplitN(os.Args[i], "=", 2)
|
||||
if len(segments) == 2 {
|
||||
n := segments[0]
|
||||
s := segments[1]
|
||||
switch {
|
||||
case strings.EqualFold(n, "--height"):
|
||||
v := parseInteger(n, s, 128, math.MaxInt)
|
||||
log.Printf("Flag: Height %d\n", v)
|
||||
featureHeight = v
|
||||
|
||||
case strings.EqualFold(n, "--width"):
|
||||
v := parseInteger(n, s, 128, math.MaxInt)
|
||||
log.Printf("Flag: Width %d\n", v)
|
||||
featureWidth = v
|
||||
|
||||
case strings.EqualFold(n, "--quality"):
|
||||
v := parseInteger(n, s, 0, 100)
|
||||
log.Printf("Flag: Quality %d\n", v)
|
||||
featureQuality = v
|
||||
|
||||
default:
|
||||
log.Printf("%s: Unknown Argument", n)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
} else {
|
||||
n := segments[0]
|
||||
if strings.EqualFold(n, "--recursive") {
|
||||
log.Println("Flag: Scanning Recursively")
|
||||
featureRecursive = true
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(n, "--extract") {
|
||||
log.Println("Flag: Extracting Images")
|
||||
featureExtract = true
|
||||
continue
|
||||
}
|
||||
flags = append(flags, segments[0])
|
||||
}
|
||||
}
|
||||
if len(flags) < 1 {
|
||||
fmt.Println("mangapub")
|
||||
fmt.Println(" --extract - Extract Images to Directory")
|
||||
fmt.Println(" --recursive - Scan Directories Recursively")
|
||||
fmt.Println(" --height=<value> - Image Height (Default: 800)")
|
||||
fmt.Println(" --width=<value> - Image Width (Default: 600)")
|
||||
fmt.Println(" --quality=<value> - JPEG Quality (Default: 25, Range: 0-100)")
|
||||
fmt.Println(" <directory> - Directory to Scan (Use \".\" for current directory)")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Process Archives
|
||||
scan([]string{})
|
||||
for _, info := range queue {
|
||||
|
||||
// Generate Paths
|
||||
directory := path.Join(info.Nest...)
|
||||
srcPath := path.Join(directory, info.Filename)
|
||||
dstPath := path.Join(OUTPUT_DIR, directory, info.Basename)
|
||||
if err := os.MkdirAll(path.Join(OUTPUT_DIR, directory), OUTPUT_FLAG); err != nil {
|
||||
log.Fatalln("Cannot create output directory:", err)
|
||||
}
|
||||
log.Printf("Converting: %s\n", srcPath)
|
||||
|
||||
// Convert Archive
|
||||
contents, err := ParseCBZ(srcPath)
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse CBZ '%s': %s\n", srcPath, err)
|
||||
continue
|
||||
}
|
||||
if featureExtract {
|
||||
if err := CreateDirectory(contents, dstPath); err != nil {
|
||||
log.Printf("Failed to create DIR '%s': %s\n", dstPath, err)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
if err := CreateEPUB(contents, dstPath); err != nil {
|
||||
log.Printf("Failed to create EPUB '%s': %s\n", dstPath, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Processing Complete
|
||||
fmt.Printf("\n")
|
||||
log.Printf("Processing Completed in %s\n", time.Since(t))
|
||||
}
|
||||
|
||||
// Parse Integer for CLI Arguments
|
||||
func parseInteger(n string, s string, min int, max int) int {
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
fmt.Printf("%s: Not A Number\n", n)
|
||||
os.Exit(1)
|
||||
}
|
||||
if v < min {
|
||||
fmt.Printf("%s: Value cannot be less than %d\n", n, min)
|
||||
os.Exit(1)
|
||||
}
|
||||
if v > max {
|
||||
fmt.Printf("%s: Value cannot be more than %d\n", n, max)
|
||||
os.Exit(1)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Scan directory and append eligible items to queue
|
||||
func scan(nesting []string) {
|
||||
if len(nesting) == 1 && strings.EqualFold(nesting[0], OUTPUT_DIR) {
|
||||
return
|
||||
}
|
||||
|
||||
// Read Entries in Directory
|
||||
directory := path.Join(nesting...)
|
||||
if directory == "" {
|
||||
directory = path.Clean(flags[0])
|
||||
nesting = []string{flags[0]}
|
||||
}
|
||||
dirEntries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
log.Fatalf("Error reading directory '%s': %s\n", directory, err)
|
||||
}
|
||||
|
||||
for _, entry := range dirEntries {
|
||||
fileName := entry.Name()
|
||||
|
||||
// Scan Subdirectory
|
||||
if entry.IsDir() {
|
||||
if featureRecursive {
|
||||
scan(append(nesting, fileName))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Add Matching File Extensions to Queue
|
||||
fileExt := path.Ext(fileName)
|
||||
if !strings.EqualFold(fileExt, ".cbz") {
|
||||
continue
|
||||
}
|
||||
queue = append(queue, QueuedItem{
|
||||
Filename: fileName,
|
||||
Basename: strings.TrimSuffix(fileName, fileExt),
|
||||
Nest: nesting,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateUUID() string {
|
||||
uuid := make([]byte, 16)
|
||||
_, err := rand.Read(uuid)
|
||||
if err != nil {
|
||||
// Fallback to a timestamp-based ID if random generation fails
|
||||
return fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// Set version (4) and variant (RFC 4122)
|
||||
uuid[6] = (uuid[6] & 0x0f) | 0x40
|
||||
uuid[8] = (uuid[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x",
|
||||
uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:16])
|
||||
}
|
||||
|
||||
func ParseCBZ(filename string) (*File, error) {
|
||||
|
||||
// CBZ files are really just zip archives
|
||||
reader, err := zip.OpenReader(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open CBZ file: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
cbzFile := &File{
|
||||
Name: filename,
|
||||
Images: []Image{},
|
||||
}
|
||||
|
||||
// Multithreaded image processing
|
||||
var wc = make(chan int, len(reader.File))
|
||||
var wg sync.WaitGroup
|
||||
var wm sync.Mutex
|
||||
for c := 0; c < runtime.NumCPU(); c++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := range wc {
|
||||
|
||||
// Ignore Directories
|
||||
file := reader.File[i]
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Read file contents inside archive
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
log.Printf("failed to open file in CBZ: %s\n", err)
|
||||
continue
|
||||
}
|
||||
d, _ := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
|
||||
// Decode Image with the appropriate decoder based on it's starting bytes
|
||||
// https://en.wikipedia.org/wiki/Magic_number_(programming)#Magic_numbers_in_files)
|
||||
var decoderImage image.Image
|
||||
var decoderError error
|
||||
switch {
|
||||
case len(d) > 3 && // JPEG
|
||||
d[0] == 0xFF && d[1] == 0xD8 && d[2] == 0xFF:
|
||||
decoderImage, decoderError = jpeg.Decode(bytes.NewReader(d))
|
||||
|
||||
case len(d) > 8 && // PNG
|
||||
d[0] == 0x89 && d[1] == 0x50 && d[2] == 0x4E && d[3] == 0x47 &&
|
||||
d[4] == 0x0D && d[5] == 0x0A && d[6] == 0x1A && d[7] == 0x0A:
|
||||
decoderImage, decoderError = png.Decode(bytes.NewReader(d))
|
||||
|
||||
case len(d) > 4 && // GIF
|
||||
d[0] == 0x47 && d[1] == 0x49 && d[2] == 0x46 && d[3] == 0x38:
|
||||
decoderImage, decoderError = gif.Decode(bytes.NewReader(d))
|
||||
|
||||
case len(d) > 12 && // WEBP
|
||||
d[0] == 0x52 && d[1] == 0x49 && d[2] == 0x46 && d[3] == 0x46 &&
|
||||
d[8] == 0x57 && d[9] == 0x45 && d[10] == 0x42 && d[11] == 0x50:
|
||||
decoderImage, decoderError = webp.Decode(bytes.NewReader(d))
|
||||
|
||||
default: // unsupported content type
|
||||
continue
|
||||
}
|
||||
if decoderError != nil {
|
||||
log.Printf("malformed image: %s\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate Scaled Height and Width
|
||||
bounds := decoderImage.Bounds()
|
||||
targetW, targetH := featureWidth, featureHeight
|
||||
iw, ih := bounds.Dx(), bounds.Dy()
|
||||
ratio := math.Min(float64(targetW)/float64(iw), float64(targetH)/float64(ih))
|
||||
sw, sh := int(float64(iw)*ratio), int(float64(ih)*ratio)
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, targetW, targetH))
|
||||
|
||||
// Resize Image (White Background)
|
||||
for x := 0; x < targetW; x++ {
|
||||
for y := 0; y < targetH; y++ {
|
||||
canvas.SetRGBA(x, y, color.RGBA{R: 255, G: 255, B: 255, A: 255})
|
||||
}
|
||||
}
|
||||
offsetX := (targetW - sw) / 2
|
||||
offsetY := (targetH - sh) / 2
|
||||
draw.CatmullRom.Scale(canvas, image.Rect(offsetX, offsetY, offsetX+sw, offsetY+sh),
|
||||
decoderImage, bounds, draw.Over, nil)
|
||||
|
||||
// Encode Resized Image into JPEG
|
||||
enc := bytes.Buffer{}
|
||||
if err := jpeg.Encode(&enc, canvas, &jpeg.Options{Quality: featureQuality}); err != nil {
|
||||
log.Printf("encoding error: %s\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Append Image to List
|
||||
wm.Lock()
|
||||
cbzFile.Images = append(cbzFile.Images, Image{
|
||||
Name: strings.TrimSuffix(path.Base(file.Name), path.Ext(file.Name)) + ".jpeg",
|
||||
Data: enc.Bytes(),
|
||||
MimeType: "image/jpeg",
|
||||
})
|
||||
wm.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for processing to complete
|
||||
for i := 0; i < len(reader.File); i++ {
|
||||
wc <- i
|
||||
}
|
||||
close(wc)
|
||||
wg.Wait()
|
||||
|
||||
// Sort Images by Name
|
||||
sort.Slice(cbzFile.Images, func(i, j int) bool {
|
||||
return cbzFile.Images[i].Name < cbzFile.Images[j].Name
|
||||
})
|
||||
return cbzFile, nil
|
||||
}
|
||||
|
||||
func CreateEPUB(input *File, filename string) error {
|
||||
|
||||
// EPUB files are really just zip archives
|
||||
writer, err := os.Create(filename + ".epub")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer writer.Close()
|
||||
|
||||
archive := zip.NewWriter(writer)
|
||||
defer archive.Close()
|
||||
|
||||
{
|
||||
// Write Mime Header
|
||||
mimetype, err := archive.CreateHeader(&zip.FileHeader{
|
||||
Name: "mimetype",
|
||||
Method: zip.Store,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create mimetype file: %w", err)
|
||||
}
|
||||
if _, err = mimetype.Write([]byte("application/epub+zip")); err != nil {
|
||||
return fmt.Errorf("failed to write mimetype file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
ID int
|
||||
Base string
|
||||
Type string
|
||||
}
|
||||
var (
|
||||
ContentTitle = strings.TrimSuffix(path.Base(input.Name), path.Ext(input.Name))
|
||||
ContentDate = time.Now().Format("2006-01-02")
|
||||
ContentUUID = GenerateUUID()
|
||||
ContentImages = make([]Item, 0, len(input.Images))
|
||||
)
|
||||
for i, image := range input.Images {
|
||||
|
||||
// Create Metadata Entry
|
||||
pathBase := fmt.Sprintf("page%03d", i+1)
|
||||
pathItem := Item{
|
||||
ID: i + 1,
|
||||
Base: pathBase,
|
||||
Type: image.MimeType,
|
||||
}
|
||||
|
||||
// Add HTML to Archive
|
||||
{
|
||||
pathOutput := fmt.Sprint("OEBPS/pages/", pathBase, ".xhtml")
|
||||
pathTemplate := "templates/page.xml"
|
||||
tmpl, err := template.ParseFS(templateFS, pathTemplate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open template file '%s': %s", pathTemplate, err)
|
||||
}
|
||||
output, err := archive.Create(pathOutput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create archive file '%s': %s", pathOutput, err)
|
||||
}
|
||||
if err := tmpl.Execute(output, pathItem); err != nil {
|
||||
return fmt.Errorf("cannot execute template file '%s': %s", pathTemplate, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add Image to Archive
|
||||
{
|
||||
pathOutput := fmt.Sprint("OEBPS/images/", pathBase, ".jpeg")
|
||||
output, err := archive.Create(pathOutput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create archive file '%s': %s", pathOutput, err)
|
||||
}
|
||||
if _, err = output.Write(image.Data); err != nil {
|
||||
return fmt.Errorf("cannot write archive file '%s': %s", pathOutput, err)
|
||||
}
|
||||
}
|
||||
|
||||
ContentImages = append(ContentImages, pathItem)
|
||||
}
|
||||
|
||||
{
|
||||
// Generate Metadata with Templates
|
||||
literals := map[string]any{
|
||||
"ContentTitle": ContentTitle,
|
||||
"ContentDate": ContentDate,
|
||||
"ContentUUID": ContentUUID,
|
||||
"ContentImages": ContentImages,
|
||||
}
|
||||
for _, meta := range [][]string{
|
||||
{"OEBPS/content.opf", "templates/content.opf"},
|
||||
{"OEBPS/toc.ncx", "templates/toc.ncx"},
|
||||
{"META-INF/container.xml", "templates/container.xml"},
|
||||
} {
|
||||
pathOutput := meta[0]
|
||||
pathTemplate := meta[1]
|
||||
tmpl, err := template.ParseFS(templateFS, pathTemplate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open template file '%s': %s", pathTemplate, err)
|
||||
}
|
||||
output, err := archive.Create(pathOutput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create archive file '%s': %s", pathOutput, err)
|
||||
}
|
||||
if err := tmpl.Execute(output, literals); err != nil {
|
||||
return fmt.Errorf("cannot execute template file '%s': %s", pathTemplate, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateDirectory(input *File, filename string) error {
|
||||
|
||||
// Create Output Directory
|
||||
if err := os.MkdirAll(filename, OUTPUT_FLAG); err != nil {
|
||||
return fmt.Errorf("failed to create output dir: %w", err)
|
||||
}
|
||||
|
||||
// Write Images
|
||||
for i, image := range input.Images {
|
||||
imageName := fmt.Sprintf("page%03d.jpeg", i+1)
|
||||
imagePath := path.Join(filename, imageName)
|
||||
if err := os.WriteFile(imagePath, image.Data, OUTPUT_FLAG); err != nil {
|
||||
return fmt.Errorf("failed to write image: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user