Compare commits

..

No commits in common. "main" and "v1.0.2" have entirely different histories.
main ... v1.0.2

62 changed files with 1399 additions and 5020 deletions

4
.gitignore vendored
View File

@ -1,4 +0,0 @@
.vscode
.idea
.DS_Store
/tmp/

View File

@ -1,7 +0,0 @@
Copyright 2022 Celogeek
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.

587
README.md
View File

@ -1,36 +1,10 @@
# go-comic-converter
Convert CBZ/CBR/Dir into EPUB for e-reader devices (Kindle Devices, ...)
Convert CBZ/CBR/Dir into Epub for e-reader devices (Kindle Devices, ...)
My goal is to make a simple, cross-platform, and fast tool to convert comics into EPUB.
My goal is to make a simple, crossplatform, and fast tool to convert comics into epub.
EPUB is now support by Amazon through [SendToKindle](https://www.amazon.com/gp/sendtokindle/), by Email or by using the App. So I've made it simple to support the size limit constraint of those services.
# Features
- Support input from zip, cbz, rar, cbr, pdf, directory
- Support all Kindle devices and kobo
- Support Landscape and Portrait mode
- Customize output image quality
- Intelligent cropping (support removing even page numbers)
- Customize brightness and contrast
- Auto contrast
- Auto rotate (if reader mainly read on portrait)
- Auto split double page (for easy read on portrait)
- Keep double page if split
- Remove blank image (empty image is removed)
- Manga or Normal mode
- Support cover page or not (first page will be taken in that case)
- Support title page (cover with embedded title and part)
- Split EPUB size for easy upload
- 3 sorting methods (depending on your source, you can ensure the page go in the right order)
- Save and reuse your own perfect settings
- Multi tasks for fast conversion
- Apple Book Compatibility Mode
- JSON output for programmatic usage
When you read the comic on a Kindle, you can customize how you read it with the `Aa` button:
- Landscape / Portrait
- Activate panel view for small device
Epub is now support by Amazon through [SendToKindle](https://www.amazon.com/gp/sendtokindle/), by Email or by using the App. So I've made it simple to support the size limit constraint of those services.
# Installation
@ -38,80 +12,41 @@ First ensure to have a working version of GO: [Installation](https://go.dev/doc/
Then install the last version of the tool:
```
$ go install github.com/celogeek/go-comic-converter/v3
go install github.com/celogeek/go-comic-converter@latest
```
To force install a specific version:
```
# specific version
$ go install github.com/celogeek/go-comic-converter/v3@v3.0.0
# main branch
$ go install github.com/celogeek/go-comic-converter/v3@main
# specific commit
$ go install github.com/celogeek/go-comic-converter/v3@COMMIT_HASH
go install github.com/celogeek/go-comic-converter@TAG
# Ex: go install github.com/celogeek/go-comic-converter@v1.0.0
```
Add GOPATH to your PATH
```
$ export PATH=$(go env GOPATH)/bin:$PATH
export PATH=$(go env GOPATH)/bin:$PATH
```
# Upgrade from V2
The configuration file structure changes in the v3 compare to v2.
You need to recreate your config and save it again.
Use the `show`, `reset` and `save` option.
# Check last version
You can check if a new version is available with:
```
$ go-comic-converter -version
go-comic-converter
Path : github.com/celogeek/go-comic-converter/v3
Sum : h1:tUFF2m/fGlOJOwC0/PlTopMfcBMprKvgr6TiQHQxEeo=
Version : v3.0.0
Available Version: v3.0.0
To install the latest version:
$ go install github.com/celogeek/go-comic-converter/v3@v3.0.0
```
# Supported image files
The supported image files are jpeg and png from the sources.
The extensions can be: `jpg`, `jpeg`, `png`, `webp`, `tiff`.
The case for extensions doesn't matter.
For the passthrough mode (format=copy), the supported extensions are: `jpg`, `jpeg`, `png`
# Usage
## Convert directory
Convert every supported image files found in the input directory:
Convert every ".jpg" file found in the input directory:
```
$ go-comic-converter -profile SR -input ~/Download/MyComic
go-comic-converter --profile KS --input ~/Download/MyComic
```
By default, it will output: ~/Download/MyComic.epub
By default it will output: ~/Download/MyComic.epub
## Convert CBZ, ZIP, CBR, RAR, PDF
Convert every supported image files found in the input directory:
Convert every ".jpg" file found in the input directory:
```
$ go-comic-converter -profile SR -input ~/Download/MyComic.[CBZ,ZIP,CBR,RAR,PDF]
go-comic-converter --profile KS --input ~/Download/MyComic.[CBZ,ZIP,CBR,RAR,PDF]
```
By default, it will output: ~/Download/MyComic.epub
By default it will output: ~/Download/MyComic.epub
## Convert with size limit
@ -120,473 +55,69 @@ If you send your ePub through Amazon service, you have some size limitation:
- App : 50Mb
- Website: 200Mb
You can split your file using the "-limitmb MB" option:
You can split your file using the "--limitmb MB" option:
```
go-comic-converter -profile SR -input ~/Download/MyComic.[CBZ,ZIP,CBR,RAR,PDF] -limitmb 200
go-comic-converter --profile KS --input ~/Download/MyComic.[CBZ,ZIP,CBR,RAR,PDF] --limitmb 200
```
If you have more than 1 file the output will be:
- ~/Download/MyComic Part 01 of 03.epub
- ~/Download/MyComic Part 02 of 03.epub
- ~/Download/MyComic PART_01.epub
- ~/Download/MyComic PART_02.epub
- ...
The ePub include as a first page:
- Title
- Part NUM / TOTAL
If the total is above 1, then the title of the EPUB include:
- Title [part/total]
## Dry run
If you want to preview what will be set during the conversion without running the conversion, then you can use the `-dry` option.
```
$ go-comic-converter -input ~/Downloads/mymanga.cbr -profile SR -auto -manga -limitmb 200 -dry
Go Comic Converter
Options:
Input : ~/Downloads/mymanga.cbr
Output : ~/Downloads/mymanga.epub
Author : GO Comic Converter
Title : mymanga
Workers : 20
Profile : SR - Standard Resolution - 1200x1920
Format : jpeg
Quality : 85
Grayscale : true
Grayscale mode : normal
Crop : true
Crop ratio : 1 Left - 1 Up - 1 Right - 3 Bottom - Limit 0% - Skip false
Auto contrast : true
Auto rotate : true
Auto split double page : true
Keep double page if split : true
No blank image : true
Manga : true
Has cover : true
Limit : 200 Mb
Strip first directory from toc : false
Sort path mode : path=alphanumeric, file=alpha
Foreground color : #000
Background color : #FFF
Resize : true
Aspect ratio : auto
Portrait only : false
Title page : always
Apple book compatibility : false
TOC:
- mymanga
- Chapter 1
- Chapter 2
- Chapter 3
```
## Dry verbose
You can choose different way to sort path and files, depending on your source. You can preview the sorted result with the option `dry-verbose` associated with `dry`.
The option `sort` allow you to change the sorting order.
```
$ go-comic-converter -input ~/Downloads/mymanga.cbr -profile SR -auto -manga -limitmb 200 -dry -dry-verbose -sort 2
Go Comic Converter
Options:
Input : ~/Downloads/mymanga.cbr
Output : ~/Downloads/mymanga.epub
Author : GO Comic Converter
Title : mymanga
Workers : 20
Profile : SR - Standard Resolution - 1200x1920
Format : jpeg
Quality : 85
Grayscale : true
Grayscale mode : normal
Crop : true
Crop ratio : 1 Left - 1 Up - 1 Right - 3 Bottom - Limit 0% - Skip false
Auto contrast : true
Auto rotate : true
Auto split double page : true
Keep double page if split : true
No blank image : true
Manga : true
Has cover : true
Limit : 200 Mb
Strip first directory from toc : false
Sort path mode : path=alphanumeric, file=alpha
Foreground color : #000
Background color : #FFF
Resize : true
Aspect ratio : auto
Portrait only : false
Title page : always
Apple book compatibility : false
TOC:
- mymanga
- Chapter 1
- Chapter 2
- Chapter 3
Cover:
- Chapter 1
- img1.jpg
Files:
- Chapter 1
- img2.jpg
- img10.jpg
- Chapter 2
- img01.jpg
- img02.jpg
- img03.jpg
- Chapter 3
- img1.jpg
- img2-3.jpg
- img4.jpg
```
## Change default settings
### Show current default option
```
$ go-comic-converter -show
Go Comic Converter
Options:
Profile : SR - Standard Resolution - 1200x1920
Format : jpeg
Quality : 85
Grayscale : true
Grayscale mode : normal
Crop : true
Crop ratio : 1 Left - 1 Up - 1 Right - 3 Bottom - Limit 0% - Skip false
Auto contrast : false
Auto rotate : false
Auto split double page : false
No blank image : true
Manga : false
Has cover : true
Strip first directory from toc : false
Sort path mode : path=alphanumeric, file=alpha
Foreground color : #000
Background color : #FFF
Resize : true
Aspect ratio : auto
Portrait only : false
Title page : always
Apple book compatibility : false
```
### Change default settings
```
$ go-comic-converter -manga -auto -profile SR -limitmb 200 -save
Go Comic Converter
Options:
Profile : SR - Standard Resolution - 1200x1920
Format : jpeg
Quality : 85
Grayscale : true
Grayscale mode : normal
Crop : true
Crop ratio : 1 Left - 1 Up - 1 Right - 3 Bottom - Limit 0% - Skip false
Auto contrast : true
Auto rotate : true
Auto split double page : true
Keep double page if split : true
Keep split double page aspect : true
No blank image : true
Manga : true
Has cover : true
Limit : 200 Mb
Strip first directory from toc : false
Sort path mode : path=alphanumeric, file=alpha
Foreground color : #000
Background color : #FFF
Resize : true
Aspect ratio : auto
Portrait only : false
Title page : always
Apple book compatibility : false
Saving to ~/.go-comic-converter.yaml
```
If you want to change a setting, you can change only one of them
```
$ go-comic-converter -manga=0 -save
Options:
Profile : SR - Standard Resolution - 1200x1920
Format : jpeg
Quality : 85
Grayscale : true
Grayscale mode : normal
Crop : true
Crop ratio : 1 Left - 1 Up - 1 Right - 3 Bottom - Limit 0% - Skip false
Auto contrast : false
Auto rotate : false
Auto split double page : false
No blank image : true
Manga : false
Has cover : true
Strip first directory from toc : false
Sort path mode : path=alphanumeric, file=alpha
Foreground color : #000
Background color : #FFF
Resize : true
Aspect ratio : auto
Portrait only : false
Title page : always
Apple book compatibility : false
Saving to ~/.go-comic-converter.yaml
```
### Reset default
To reset all value to default:
```
$ go-comic-converter -reset
Go Comic Converter
Options:
Profile : SR - Standard Resolution - 1200x1920
Format : jpeg
Quality : 85
Grayscale : true
Grayscale mode : normal
Crop : true
Crop ratio : 1 Left - 1 Up - 1 Right - 3 Bottom - Limit 0% - Skip false
Auto contrast : false
Auto rotate : false
Auto split double page : false
No blank image : true
Manga : false
Has cover : true
Strip first directory from toc : false
Sort path mode : path=alphanumeric, file=alpha
Foreground color : #000
Background color : #FFF
Resize : true
Aspect ratio : auto
Portrait only : false
Title page : always
Apple book compatibility : false
Reset default to ~/.go-comic-converter.yaml
```
# My own settings
After playing around with the options, I have my perfect settings for all my devices.
```
$ go-comic-converter -reset
$ go-comic-converter -profile SR -quality 90 -manga -aspect-ratio 1.6 -limitmb 200 -save
Options:
Profile : SR - Standard Resolution - 1200x1920
Format : jpeg
Quality : 90
Grayscale : true
Grayscale mode : normal
Crop : true
Crop ratio : 1 Left - 1 Up - 1 Right - 3 Bottom - Limit 0% - Skip false
Auto contrast : false
Auto rotate : false
Auto split double page : false
No blank image : true
Manga : true
Has cover : true
Limit : 200 Mb
Strip first directory from toc : false
Sort path mode : path=alphanumeric, file=alpha
Foreground color : #000
Background color : #FFF
Resize : true
Aspect ratio : 1:1.60
Portrait only : false
Title page : always
Apple book compatibility : false
Saving to ~/.go-comic-converter.yaml
```
Explanation:
- `-profile SR`: standard resolution (fast conversion from Amazon as images do not need to be resized)
- `-quality 90`: JPEG output quality of images
- `-manga`: manga mode, read right to left
- `-limitmb 200`: size limit to 200MB allowing upload from SendToKindle website
- `-aspect-ratio`: ensure aspect ratio is 1:1.6, best for kindle devices.
# Help
```
$ go-comic-converter -h
# go-comic-converter -h
Usage of go-comic-converter:
Output:
-algo string
Algo for RGB to Grayscale: luster, default, mean, luma (default "default")
-author string
Author of the epub (default "GO Comic Converter")
-input string
Source of comic to convert: directory, cbz, zip, cbr, rar, pdf
-output string
Output of the EPUB (directory or EPUB): (default [INPUT].epub)
-author string (default "GO Comic Converter")
Author of the EPUB
-title string
Title of the EPUB
Config:
-profile string (default "SR")
Profile to use:
- KoAO - 1404 x 1872 - Kobo Aura ONE
- KoF - 1440 x 1920 - Kobo Forma
- KoE - 1404 x 1872 - Kobo Elipsa
- KV - 1072 x 1448 - Kindle Paperwhite 3/4/Voyage/Oasis
- KoG - 768 x 1024 - Kobo Glo
- KoA - 758 x 1024 - Kobo Aura
- RM1 - 1404 x 1872 - reMarkable 1
- RM2 - 1404 x 1872 - reMarkable 2
- K1 - 600 x 670 - Kindle 1
- K11 - 1072 x 1448 - Kindle 11
- K2 - 600 x 670 - Kindle 2
- K34 - 600 x 800 - Kindle Keyboard/Touch
- KPW5 - 1236 x 1648 - Kindle Paperwhite 5/Signature Edition
- KoAH2O - 1080 x 1430 - Kobo Aura H2O
- KoN - 758 x 1024 - Kobo Nia
- KoL - 1264 x 1680 - Kobo Libra H2O/Kobo Libra 2
- HR - 2400 x 3840 - High Resolution
- KO - 1264 x 1680 - Kindle Oasis 2/3
- KS - 1860 x 2480 - Kindle Scribe
- KoMT - 600 x 800 - Kobo Mini/Touch
- KoAHD - 1080 x 1440 - Kobo Aura HD
- KoC - 1072 x 1448 - Kobo Clara HD/Kobo Clara 2E
- KoS - 1440 x 1920 - Kobo Sage
- SR - 1200 x 1920 - Standard Resolution
- K578 - 600 x 800 - Kindle
- KDX - 824 x 1000 - Kindle DX/DXG
- KPW - 758 x 1024 - Kindle Paperwhite 1/2
- KoGHD - 1072 x 1448 - Kobo Glo HD
-quality int (default 85)
Quality of the image
-grayscale (default true)
Grayscale image. Ideal for eInk devices.
-grayscale-mode int
Grayscale Mode
0 = normal
1 = average
2 = luminance
-crop (default true)
Crop images
-crop-ratio-left int (default 1)
Crop ratio left: ratio of pixels allow to be non blank while cutting on the left.
-crop-ratio-up int (default 1)
Crop ratio up: ratio of pixels allow to be non blank while cutting on the top.
-crop-ratio-right int (default 1)
Crop ratio right: ratio of pixels allow to be non blank while cutting on the right.
-crop-ratio-bottom int (default 3)
Crop ratio bottom: ratio of pixels allow to be non blank while cutting on the bottom.
-crop-limit int
Crop limit: maximum number of cropping in percentage allowed. 0 mean unlimited.
-crop-skip-if-limit-reached
Crop skip if limit reached.
-brightness int
Brightness readjustment: between -100 and 100, > 0 lighter, < 0 darker
-contrast int
Contrast readjustment: between -100 and 100, > 0 more contrast, < 0 less contrast
-autocontrast
Improve contrast automatically
-autorotate
Auto Rotate page when width > height
-autosplitdoublepage
Auto Split double page when width > height
-keepdoublepageifsplit (default true)
Keep the double page if split
-keepsplitdoublepageaspect (default true)
Keep aspect of split part of a double page (best for landscape rendering)
-noblankimage (default true)
Remove blank image
-manga
Manga mode (right to left)
-hascover (default true)
Has cover. Indicate if your comic have a cover. The first page will be used as a cover and include after the title.
-limitmb int
Limit size of the EPUB: Default nolimit (0), Minimum 20
-strip
Strip first directory from the TOC if only 1
-sort int (default 1)
Sort path mode
0 = alpha for path and file
1 = alphanumeric for path and alpha for file
2 = alphanumeric for path and file
-foreground-color string (default "000")
Foreground color in hexadecimal format RGB. Black=000, White=FFF
-background-color string (default "FFF")
Background color in hexadecimal format RGB. Black=000, White=FFF, Light Gray=DDD, Dark Gray=777
-resize (default true)
Reduce image size if exceed device size
-format string (default "jpeg")
Format of output images: jpeg (lossy), png (lossless), copy (no processing)
-aspect-ratio float
Aspect ratio (height/width) of the output
-1 = same as device
0 = same as source
1.6 = amazon advice for kindle
-portrait-only
Portrait only: force orientation to portrait only.
-titlepage int (default 1)
Title page
0 = never
1 = always
2 = only if epub is split
Default config:
-show
Show your default parameters
-save
Save your parameters as default
-reset
Reset your parameters to default
Shortcut:
-auto
Activate all automatic options
-nofilter
Deactivate all filters
-maxquality
Max quality: color png + noresize
-bestquality
Max quality: color jpg q100 + noresize
-greatquality
Max quality: grayscale jpg q90 + noresize
-goodquality
Max quality: grayscale jpg q90
Compatibility:
-applebookcompatibility
Apple book compatibility
Other:
-workers int (default number of CPUs)
Number of workers
-dry
Dry run to show all options
-dry-verbose
Display also sorted files after the TOC
-quiet
Disable progress bar
-json
Output progression and information in Json format
-version
Show current and available version
-help
Show this help message
Limit size of the ePub: Default nolimit (0), Minimum 20
-nocrop
Disable cropping
-output string
Output of the epub (directory or epub): (default [INPUT].epub)
-profile string
Profile to use:
- K1 ( 600x670 ) - Kindle 1
- K11 ( 1072x1448 ) - Kindle 11
- K2 ( 600x670 ) - Kindle 2
- K34 ( 600x800 ) - Kindle Keyboard/Touch
- K578 ( 600x800 ) - Kindle
- KDX ( 824x1000 ) - Kindle DX/DXG
- KPW ( 758x1024 ) - Kindle Paperwhite 1/2
- KV ( 1072x1448 ) - Kindle Paperwhite 3/4/Voyage/Oasis
- KPW5 ( 1236x1648 ) - Kindle Paperwhite 5/Signature Edition
- KO ( 1264x1680 ) - Kindle Oasis 2/3
- KS ( 1860x2480 ) - Kindle Scribe
- KoMT ( 600x800 ) - Kobo Mini/Touch
- KoG ( 768x1024 ) - Kobo Glo
- KoGHD ( 1072x1448 ) - Kobo Glo HD
- KoA ( 758x1024 ) - Kobo Aura
- KoAHD ( 1080x1440 ) - Kobo Aura HD
- KoAH2O ( 1080x1430 ) - Kobo Aura H2O
- KoAO ( 1404x1872 ) - Kobo Aura ONE
- KoN ( 758x1024 ) - Kobo Nia
- KoC ( 1072x1448 ) - Kobo Clara HD/Kobo Clara 2E
- KoL ( 1264x1680 ) - Kobo Libra H2O/Kobo Libra 2
- KoF ( 1440x1920 ) - Kobo Forma
- KoS ( 1440x1920 ) - Kobo Sage
- KoE ( 1404x1872 ) - Kobo Elipsa
-quality int
Quality of the image (default 85)
-title string
Title of the epub
```
# Credit
@ -595,7 +126,3 @@ This project is largely inspired from KCC (Kindle Comic Converter). Thanks:
- [ciromattia](https://github.com/ciromattia/kcc)
- [darodi fork](https://github.com/darodi/kcc)
# UI
Thanks for UI contribution:
- [manueldidonna / Comic2Books](https://github.com/manueldidonna/comic2books)

29
go.mod
View File

@ -1,28 +1,19 @@
module github.com/celogeek/go-comic-converter/v3
module github.com/celogeek/go-comic-converter
go 1.23
go 1.19
require (
github.com/beevik/etree v1.5.0
github.com/disintegration/gift v1.2.1
github.com/fogleman/gg v1.3.0
github.com/gofrs/uuid v4.4.0+incompatible
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/nwaples/rardecode/v2 v2.1.0
github.com/gofrs/uuid v4.3.1+incompatible
github.com/nwaples/rardecode v1.1.3
github.com/raff/pdfreader v0.0.0-20220308062436-033e8ac577f0
github.com/schollz/progressbar/v3 v3.18.0
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e
golang.org/x/image v0.24.0
gopkg.in/yaml.v3 v3.0.1
github.com/schollz/progressbar/v3 v3.12.2
golang.org/x/image v0.2.0
)
require (
github.com/google/go-github v17.0.0+incompatible // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/term v0.29.0 // indirect
github.com/rivo/uniseg v0.4.3 // indirect
golang.org/x/sys v0.3.0 // indirect
golang.org/x/term v0.3.0 // indirect
)

93
go.sum
View File

@ -1,53 +1,56 @@
github.com/beevik/etree v1.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs=
github.com/beevik/etree v1.5.0/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs=
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY=
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI=
github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
github.com/nwaples/rardecode/v2 v2.1.0 h1:JQl9ZoBPDy+nIZGb1mx8+anfHp/LV3NE2MjMiv0ct/U=
github.com/nwaples/rardecode/v2 v2.1.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/raff/pdfreader v0.0.0-20220308062436-033e8ac577f0 h1:fuFvfwIc+cpySYurvDNTs5LIHXP9Cj3reVRplj9Whv4=
github.com/raff/pdfreader v0.0.0-20220308062436-033e8ac577f0/go.mod h1:Ql3QqeGiYGlPOtYz+F/L7J27spqDcdH9LhDHOrrdsD4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e h1:IWllFTiDjjLIf2oeKxpIUmtiDV5sn71VgeQgg6vcE7k=
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e/go.mod h1:d7u6HkTYKSv5m6MCKkOQlHwaShTMl3HjqSGW3XtVhXM=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/schollz/progressbar/v3 v3.12.2 h1:yLqqqpQNMxGxHY8uEshRihaHWwa0rf0yb7/Zrpgq2C0=
github.com/schollz/progressbar/v3 v3.12.2/go.mod h1:HFJYIYQQJX32UJdyoigUl19xoV6aMwZt6iX/C30RWfg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/image v0.2.0 h1:/DcQ0w3VHKCC5p0/P2B0JpAZ9Z++V2KOo2fyU89CXBQ=
golang.org/x/image v0.2.0/go.mod h1:la7oBXb9w3YFjBqaAwtynVioc1ZvOnNteUNrifGNmAI=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

194
internal/epub/core.go Normal file
View File

@ -0,0 +1,194 @@
package epub
import (
"fmt"
"path/filepath"
"strings"
"text/template"
"time"
"github.com/gofrs/uuid"
)
type ImageOptions struct {
Crop bool
ViewWidth int
ViewHeight int
Quality int
Algo string
}
type EpubOptions struct {
Input string
Output string
Title string
Author string
LimitMb int
*ImageOptions
}
type ePub struct {
*EpubOptions
UID string
Publisher string
UpdatedAt string
templateProcessor *template.Template
}
type epubPart struct {
Cover *Image
Images []*Image
}
func NewEpub(options *EpubOptions) *ePub {
uid, err := uuid.NewV4()
if err != nil {
panic(err)
}
tmpl := template.New("parser")
tmpl.Funcs(template.FuncMap{
"mod": func(i, j int) bool { return i%j == 0 },
"zoom": func(s int, z float32) int { return int(float32(s) * z) },
})
return &ePub{
EpubOptions: options,
UID: uid.String(),
Publisher: "GO Comic Converter",
UpdatedAt: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
templateProcessor: tmpl,
}
}
func (e *ePub) render(templateString string, data any) string {
tmpl, err := e.templateProcessor.Parse(templateString)
if err != nil {
panic(err)
}
result := &strings.Builder{}
if err := tmpl.Execute(result, data); err != nil {
panic(err)
}
return result.String()
}
func (e *ePub) getParts() ([]*epubPart, error) {
images, err := LoadImages(e.Input, e.ImageOptions)
if err != nil {
return nil, err
}
parts := make([]*epubPart, 0)
cover := images[0]
images = images[1:]
if e.LimitMb == 0 {
parts = append(parts, &epubPart{
Cover: cover,
Images: images,
})
return parts, nil
}
maxSize := uint64(e.LimitMb * 1024 * 1024)
xhtmlSize := uint64(1024)
// descriptor files + image
baseSize := uint64(16*1024) + cover.Data.CompressedSize()
currentSize := baseSize
currentImages := make([]*Image, 0)
part := 1
for _, img := range images {
imgSize := img.Data.CompressedSize() + xhtmlSize
if len(currentImages) > 0 && currentSize+imgSize > maxSize {
parts = append(parts, &epubPart{
Cover: cover,
Images: currentImages,
})
part += 1
currentSize = baseSize
currentImages = make([]*Image, 0)
}
currentSize += imgSize
currentImages = append(currentImages, img)
}
if len(currentImages) > 0 {
parts = append(parts, &epubPart{
Cover: cover,
Images: currentImages,
})
}
return parts, nil
}
func (e *ePub) Write() error {
type zipContent struct {
Name string
Content any
}
epubParts, err := e.getParts()
if err != nil {
return err
}
totalParts := len(epubParts)
bar := NewBar(totalParts, "Writing Part", 2, 2)
for i, part := range epubParts {
ext := filepath.Ext(e.Output)
suffix := ""
if totalParts > 1 {
suffix = fmt.Sprintf(" PART_%02d", i+1)
}
path := fmt.Sprintf("%s%s%s", e.Output[0:len(e.Output)-len(ext)], suffix, ext)
wz, err := newEpubZip(path)
if err != nil {
return err
}
defer wz.Close()
content := []zipContent{
{"META-INF/container.xml", containerTmpl},
{"OEBPS/content.opf", e.render(contentTmpl, map[string]any{"Info": e, "Images": part.Images})},
{"OEBPS/toc.ncx", e.render(tocTmpl, map[string]any{"Info": e})},
{"OEBPS/nav.xhtml", e.render(navTmpl, map[string]any{"Info": e})},
{"OEBPS/Text/style.css", styleTmpl},
{"OEBPS/Text/part.xhtml", e.render(partTmpl, map[string]any{
"Info": e,
"Part": i + 1,
"Total": totalParts,
})},
}
if err = wz.WriteMagic(); err != nil {
return err
}
for _, content := range content {
if err := wz.WriteFile(content.Name, content.Content); err != nil {
return err
}
}
wz.WriteImage(part.Cover.Data)
for _, img := range part.Images {
text := fmt.Sprintf("OEBPS/Text/%d.xhtml", img.Id)
if err := wz.WriteFile(text, e.render(textTmpl, img)); err != nil {
return err
}
if err := wz.WriteImage(img.Data); err != nil {
return err
}
}
bar.Add(1)
}
bar.Close()
return nil
}

View File

@ -0,0 +1,44 @@
package epub
import (
"archive/zip"
"bytes"
"compress/flate"
"hash/crc32"
"time"
)
type ImageData struct {
Header *zip.FileHeader
Data []byte
}
func (img *ImageData) CompressedSize() uint64 {
return img.Header.CompressedSize64 + 30 + uint64(len(img.Header.Name))
}
func newImageData(name string, data []byte) *ImageData {
cdata := bytes.NewBuffer([]byte{})
wcdata, err := flate.NewWriter(cdata, flate.BestCompression)
if err != nil {
panic(err)
}
wcdata.Write(data)
wcdata.Close()
if err != nil {
panic(err)
}
t := time.Now()
return &ImageData{
&zip.FileHeader{
Name: name,
CompressedSize64: uint64(cdata.Len()),
UncompressedSize64: uint64(len(data)),
CRC32: crc32.Checksum(data, crc32.IEEETable),
Method: zip.Deflate,
ModifiedTime: uint16(t.Second()/2 + t.Minute()<<5 + t.Hour()<<11),
ModifiedDate: uint16(t.Day() + int(t.Month())<<5 + (t.Year()-1980)<<9),
},
cdata.Bytes(),
}
}

View File

@ -0,0 +1,316 @@
package epub
import (
"archive/zip"
"bytes"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
imageconverter "github.com/celogeek/go-comic-converter/internal/image-converter"
"github.com/nwaples/rardecode"
pdfimage "github.com/raff/pdfreader/image"
"github.com/raff/pdfreader/pdfread"
"golang.org/x/image/tiff"
)
type Image struct {
Id int
Data *ImageData
Width int
Height int
}
type imageTask struct {
Id int
Reader io.ReadCloser
}
func LoadImages(path string, options *ImageOptions) ([]*Image, error) {
images := make([]*Image, 0)
fi, err := os.Stat(path)
if err != nil {
return nil, err
}
var (
imageCount int
imageInput chan *imageTask
)
if fi.IsDir() {
imageCount, imageInput, err = loadDir(path)
} else {
switch ext := strings.ToLower(filepath.Ext(path)); ext {
case ".cbz", ".zip":
imageCount, imageInput, err = loadCbz(path)
case ".cbr", ".rar":
imageCount, imageInput, err = loadCbr(path)
case ".pdf":
imageCount, imageInput, err = loadPdf(path)
default:
err = fmt.Errorf("unknown file format (%s): support .cbz, .zip, .cbr, .rar, .pdf", ext)
}
}
if err != nil {
return nil, err
}
imageOutput := make(chan *Image)
// processing
wg := &sync.WaitGroup{}
bar := NewBar(imageCount, "Processing", 1, 2)
for i := 0; i < runtime.NumCPU(); i++ {
wg.Add(1)
go func() {
defer wg.Done()
for img := range imageInput {
data, w, h := imageconverter.Convert(
img.Reader,
options.Crop,
options.ViewWidth,
options.ViewHeight,
options.Quality,
options.Algo,
)
name := fmt.Sprintf("OEBPS/Images/%d.jpg", img.Id)
if img.Id == 0 {
name = "OEBPS/Images/cover.jpg"
}
imageOutput <- &Image{
img.Id,
newImageData(name, data),
w,
h,
}
}
}()
}
go func() {
wg.Wait()
bar.Close()
close(imageOutput)
}()
for image := range imageOutput {
images = append(images, image)
bar.Add(1)
}
if len(images) == 0 {
return nil, fmt.Errorf("image not found")
}
sort.Slice(images, func(i, j int) bool {
return images[i].Id < images[j].Id
})
return images, nil
}
func loadDir(input string) (int, chan *imageTask, error) {
images := make([]string, 0)
err := filepath.WalkDir(input, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
ext := filepath.Ext(path)
if strings.ToLower(ext) != ".jpg" {
return nil
}
images = append(images, path)
return nil
})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if len(images) == 0 {
return 0, nil, fmt.Errorf("image not found")
}
sort.Strings(images)
output := make(chan *imageTask)
go func() {
defer close(output)
for i, img := range images {
f, err := os.Open(img)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
output <- &imageTask{
Id: i,
Reader: f,
}
}
}()
return len(images), output, nil
}
func loadCbz(input string) (int, chan *imageTask, error) {
r, err := zip.OpenReader(input)
if err != nil {
return 0, nil, err
}
images := make([]*zip.File, 0)
for _, f := range r.File {
if f.FileInfo().IsDir() {
continue
}
if strings.ToLower(filepath.Ext(f.Name)) != ".jpg" {
continue
}
images = append(images, f)
}
if len(images) == 0 {
r.Close()
return 0, nil, fmt.Errorf("no images found")
}
sort.SliceStable(images, func(i, j int) bool {
return strings.Compare(images[i].Name, images[j].Name) < 0
})
output := make(chan *imageTask)
go func() {
defer close(output)
for i, img := range images {
f, err := img.Open()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
output <- &imageTask{
Id: i,
Reader: f,
}
}
}()
return len(images), output, nil
}
func loadCbr(input string) (int, chan *imageTask, error) {
// listing and indexing
rl, err := rardecode.OpenReader(input, "")
if err != nil {
return 0, nil, err
}
names := make([]string, 0)
for {
f, err := rl.Next()
if err != nil && err != io.EOF {
rl.Close()
return 0, nil, err
}
if f == nil {
break
}
if f.IsDir {
continue
}
if strings.ToLower(filepath.Ext(f.Name)) != ".jpg" {
continue
}
names = append(names, f.Name)
}
rl.Close()
if len(names) == 0 {
return 0, nil, fmt.Errorf("no images found")
}
sort.Strings(names)
indexedNames := make(map[string]int)
for i, name := range names {
indexedNames[name] = i
}
// send file to the queue
output := make(chan *imageTask)
go func() {
defer close(output)
r, err := rardecode.OpenReader(input, "")
if err != nil {
panic(err)
}
defer r.Close()
for {
f, err := r.Next()
if err != nil && err != io.EOF {
panic(err)
}
if f == nil {
break
}
if idx, ok := indexedNames[f.Name]; ok {
b := bytes.NewBuffer([]byte{})
io.Copy(b, r)
output <- &imageTask{
Id: idx,
Reader: io.NopCloser(b),
}
}
}
}()
return len(names), output, nil
}
func loadPdf(input string) (int, chan *imageTask, error) {
pdf := pdfread.Load(input)
if pdf == nil {
return 0, nil, fmt.Errorf("can't read pdf")
}
nbPages := len(pdf.Pages())
output := make(chan *imageTask)
go func() {
defer close(output)
defer pdf.Close()
for i := 0; i < nbPages; i++ {
img, err := pdfimage.Extract(pdf, i+1)
if err != nil {
panic(err)
}
b := bytes.NewBuffer([]byte{})
err = tiff.Encode(b, img, nil)
if err != nil {
panic(err)
}
output <- &imageTask{
Id: i,
Reader: io.NopCloser(b),
}
}
}()
return nbPages, output, nil
}

31
internal/epub/progress.go Normal file
View File

@ -0,0 +1,31 @@
package epub
import (
"fmt"
"os"
"github.com/schollz/progressbar/v3"
)
func NewBar(max int, description string, currentJob, totalJob int) *progressbar.ProgressBar {
fmtJob := fmt.Sprintf("%%0%dd", len(fmt.Sprint(totalJob)))
fmtDesc := fmt.Sprintf("[%s/%s] %%-15s", fmtJob, fmtJob)
return progressbar.NewOptions(max,
progressbar.OptionSetWriter(os.Stderr),
progressbar.OptionOnCompletion(func() {
fmt.Fprint(os.Stderr, "\n")
}),
progressbar.OptionSetDescription(fmt.Sprintf(fmtDesc, currentJob, totalJob, description)),
progressbar.OptionSetWidth(60),
progressbar.OptionShowCount(),
progressbar.OptionSetRenderBlankState(true),
progressbar.OptionEnableColorCodes(true),
progressbar.OptionSetTheme(progressbar.Theme{
Saucer: "[green]=[reset]",
SaucerHead: "[green]>[reset]",
SaucerPadding: " ",
BarStart: "[",
BarEnd: "]",
}),
)
}

View File

@ -0,0 +1,24 @@
package epub
import _ "embed"
//go:embed "templates/container.xml.tmpl"
var containerTmpl string
//go:embed "templates/content.opf.tmpl"
var contentTmpl string
//go:embed "templates/toc.ncx.tmpl"
var tocTmpl string
//go:embed "templates/nav.xhtml.tmpl"
var navTmpl string
//go:embed "templates/style.css.tmpl"
var styleTmpl string
//go:embed "templates/part.xhtml.tmpl"
var partTmpl string
//go:embed "templates/text.xhtml.tmpl"
var textTmpl string

View File

@ -0,0 +1,6 @@
<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<package version="3.0" unique-identifier="BookID" xmlns="http://www.idpf.org/2007/opf">
<metadata xmlns:opf="http://www.idpf.org/2007/opf" xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>{{ .Info.Title }}</dc:title>
<dc:language>en-US</dc:language>
<dc:identifier id="BookID">urn:uuid:{{ .Info.UID }}</dc:identifier>
<dc:contributor id="contributor">GO Comic Converter</dc:contributor>
<dc:creator>GO Comic Converter</dc:creator>
<meta property="dcterms:modified">{{ .Info.UpdatedAt }}</meta>
<meta name="cover" content="cover"/>
<meta name="fixed-layout" content="true"/>
<meta name="original-resolution" content="{{ .Info.ViewWidth }}x{{ .Info.ViewHeight }}"/>
<meta name="book-type" content="comic"/>
<meta name="primary-writing-mode" content="horizontal-lr"/>
<meta name="zero-gutter" content="true"/>
<meta name="zero-margin" content="true"/>
<meta name="ke-border-color" content="#FFFFFF"/>
<meta name="ke-border-width" content="0"/>
<meta name="orientation-lock" content="portrait"/>
<meta name="region-mag" content="true"/>
</metadata>
<manifest>
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
<item id="nav" href="nav.xhtml" properties="nav" media-type="application/xhtml+xml"/>
<item id="cover" href="Images/cover.jpg" media-type="image/jpeg" properties="cover-image"/>
<item id="css" href="Text/style.css" media-type="text/css"/>
<item id="page_part" href="Text/part.xhtml" media-type="application/xhtml+xml"/>
{{ range .Images }}
<item id="page_{{ .Id }}" href="Text/{{ .Id }}.xhtml" media-type="application/xhtml+xml"/>
<item id="img_{{ .Id }}" href="Images/{{ .Id }}.jpg" media-type="image/jpeg"/>
{{ end }}
</manifest>
<spine page-progression-direction="ltr" toc="ncx">
<itemref idref="page_part" linear="yes" properties="page-spread-right"/>
{{ range $idx, $ := .Images }}
{{ if mod $idx 2 }}
<itemref idref="page_{{ $.Id }}" linear="yes" properties="page-spread-left"/>
{{ else }}
<itemref idref="page_{{ $.Id }}" linear="yes" properties="page-spread-right"/>
{{ end }}
{{ end }}
</spine>
</package>

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head>
<title>{{ .Info.Title }}</title>
<meta charset="utf-8"/>
</head>
<body>
<nav xmlns:epub="http://www.idpf.org/2007/ops" epub:type="toc" id="toc">
<ol>
<li><a href="Text/part.xhtml">{{ .Info.Title }}</a></li>
</ol>
</nav>
<nav epub:type="page-list">
<ol>
<li><a href="Text/part.xhtml">{{ .Info.Title }}</a></li>
</ol>
</nav>
</body>
</html>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head>
<title>Part {{ .Part }}</title>
<link href="style.css" type="text/css" rel="stylesheet"/>
<meta name="viewport" content="width={{ .Info.ViewWidth }}, height={{ .Info.ViewHeight }}"/>
</head>
<body style="">
<div style="text-align:center;top:0.0%;">
<h1>{{ .Info.Title }}</h1>
<h1>Part {{ .Part }} / {{ .Total }}</h1>
</div>
</body>
</html>

View File

@ -0,0 +1,83 @@
@page {
margin: 0;
}
body {
display: block;
margin: 0;
padding: 0;
}
#PV {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
#PV-T {
top: 0;
width: 100%;
height: 50%;
}
#PV-B {
bottom: 0;
width: 100%;
height: 50%;
}
#PV-L {
left: 0;
width: 49.5%;
height: 100%;
float: left;
}
#PV-R {
right: 0;
width: 49.5%;
height: 100%;
float: right;
}
#PV-TL {
top: 0;
left: 0;
width: 49.5%;
height: 50%;
float: left;
}
#PV-TR {
top: 0;
right: 0;
width: 49.5%;
height: 50%;
float: right;
}
#PV-BL {
bottom: 0;
left: 0;
width: 49.5%;
height: 50%;
float: left;
}
#PV-BR {
bottom: 0;
right: 0;
width: 49.5%;
height: 50%;
float: right;
}
.PV-P {
width: 100%;
height: 100%;
top: 0;
position: absolute;
display: none;
}

View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head>
<title>Page {{ .Id }}</title>
<link href="style.css" type="text/css" rel="stylesheet"/>
<meta name="viewport" content="width={{ .Width }}, height={{ .Height }}"/>
</head>
<body style="">
<div style="text-align:center;top:0.0%;">
<img width="{{ .Width }}" height="{{ .Height }}" src="../Images/{{ .Id }}.jpg"/>
</div>
<div id="PV">
<div id="PV-TL">
<a style="display:inline-block;width:100%;height:100%;" class="app-amzn-magnify" data-app-amzn-magnify='{"targetId":"PV-TL-P", "ordinal":1}'></a>
</div>
<div id="PV-TR">
<a style="display:inline-block;width:100%;height:100%;" class="app-amzn-magnify" data-app-amzn-magnify='{"targetId":"PV-TR-P", "ordinal":2}'></a>
</div>
<div id="PV-BL">
<a style="display:inline-block;width:100%;height:100%;" class="app-amzn-magnify" data-app-amzn-magnify='{"targetId":"PV-BL-P", "ordinal":3}'></a>
</div>
<div id="PV-BR">
<a style="display:inline-block;width:100%;height:100%;" class="app-amzn-magnify" data-app-amzn-magnify='{"targetId":"PV-BR-P", "ordinal":4}'></a>
</div>
</div>
<div class="PV-P" id="PV-TL-P" style="">
<img style="position:absolute;left:0;top:0;" src="../Images/{{ .Id }}.jpg" width="{{ zoom .Width 1.5 }}" height="{{ zoom .Height 1.5 }}"/>
</div>
<div class="PV-P" id="PV-TR-P" style="">
<img style="position:absolute;right:0;top:0;" src="../Images/{{ .Id }}.jpg" width="{{ zoom .Width 1.5 }}" height="{{ zoom .Height 1.5 }}"/>
</div>
<div class="PV-P" id="PV-BL-P" style="">
<img style="position:absolute;left:0;bottom:0;" src="../Images/{{ .Id }}.jpg" width="{{ zoom .Width 1.5 }}" height="{{ zoom .Height 1.5 }}"/>
</div>
<div class="PV-P" id="PV-BR-P" style="">
<img style="position:absolute;right:0;bottom:0;" src="../Images/{{ .Id }}.jpg" width="{{ zoom .Width 1.5 }}" height="{{ zoom .Height 1.5 }}"/>
</div>
</body>
</html>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<ncx version="2005-1" xml:lang="en-US" xmlns="http://www.daisy.org/z3986/2005/ncx/">
<head>
<meta name="dtb:uid" content="urn:uuid:{{ .Info.UID }}"/>
<meta name="dtb:depth" content="1"/>
<meta name="dtb:totalPageCount" content="0"/>
<meta name="dtb:maxPageNumber" content="0"/>
<meta name="generated" content="true"/>
</head>
<docTitle><text>{{ .Info.Title }}</text></docTitle>
<navMap>
<navPoint id="Text"><navLabel><text>{{ .Info.Title }}</text></navLabel><content src="Text/part.xhtml"/></navPoint>
</navMap>
</ncx>

83
internal/epub/zip.go Normal file
View File

@ -0,0 +1,83 @@
package epub
import (
"archive/zip"
"fmt"
"os"
"time"
)
type epubZip struct {
w *os.File
wz *zip.Writer
}
func newEpubZip(path string) (*epubZip, error) {
w, err := os.Create(path)
if err != nil {
return nil, err
}
wz := zip.NewWriter(w)
return &epubZip{w, wz}, nil
}
func (e *epubZip) Close() error {
if err := e.wz.Close(); err != nil {
return err
}
return e.w.Close()
}
func (e *epubZip) WriteMagic() error {
t := time.Now()
fh := &zip.FileHeader{
Name: "mimetype",
Method: zip.Store,
Modified: t,
ModifiedTime: uint16(t.Second()/2 + t.Minute()<<5 + t.Hour()<<11),
ModifiedDate: uint16(t.Day() + int(t.Month())<<5 + (t.Year()-1980)<<9),
CompressedSize64: 20,
UncompressedSize64: 20,
CRC32: 0x2cab616f,
}
fh.SetMode(0600)
m, err := e.wz.CreateRaw(fh)
if err != nil {
return err
}
_, err = m.Write([]byte("application/epub+zip"))
return err
}
func (e *epubZip) WriteImage(image *ImageData) error {
m, err := e.wz.CreateRaw(image.Header)
if err != nil {
return err
}
_, err = m.Write(image.Data)
return err
}
func (e *epubZip) WriteFile(file string, data any) error {
var content []byte
switch b := data.(type) {
case string:
content = []byte(b)
case []byte:
content = b
default:
return fmt.Errorf("support string of []byte")
}
m, err := e.wz.CreateHeader(&zip.FileHeader{
Name: file,
Modified: time.Now(),
Method: zip.Deflate,
})
if err != nil {
return err
}
_, err = m.Write(content)
return err
}

View File

@ -0,0 +1,167 @@
package comicconverter
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"io"
"sort"
"golang.org/x/image/draw"
)
var AlgoGray = map[string]func(color.Color) color.Color{
"default": func(c color.Color) color.Color {
return color.GrayModel.Convert(c)
},
"mean": func(c color.Color) color.Color {
r, g, b, _ := c.RGBA()
y := float64(r+g+b) / 3 * (255.0 / 65535)
return color.Gray{uint8(y)}
},
"luma": func(c color.Color) color.Color {
r, g, b, _ := c.RGBA()
y := (0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(b)) * (255.0 / 65535)
return color.Gray{uint8(y)}
},
"luster": func(c color.Color) color.Color {
r, g, b, _ := c.RGBA()
arr := []float64{float64(r), float64(g), float64(b)}
sort.Float64s(arr)
y := (arr[0] + arr[2]) / 2 * (255.0 / 65535)
return color.Gray{uint8(y)}
},
}
func toGray(img image.Image, algo string) *image.Gray {
grayImg := image.NewGray(img.Bounds())
algoConv, ok := AlgoGray[algo]
if !ok {
panic("wrong gray algo")
}
for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y++ {
for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ {
grayImg.Set(x, y, algoConv(img.At(x, y)))
}
}
return grayImg
}
func Load(reader io.ReadCloser, algo string) *image.Gray {
defer reader.Close()
img, _, err := image.Decode(reader)
if err != nil {
panic(err)
}
switch imgt := img.(type) {
case *image.Gray:
return imgt
default:
return toGray(img, algo)
}
}
func isBlank(c color.Color) bool {
r, g, b, _ := c.RGBA()
return r > 60000 && g > 60000 && b > 60000
}
func CropMarging(img *image.Gray) *image.Gray {
imgArea := img.Bounds()
LEFT:
for x := imgArea.Min.X; x < imgArea.Max.X; x++ {
for y := imgArea.Min.Y; y < imgArea.Max.Y; y++ {
if !isBlank(img.At(x, y)) {
break LEFT
}
}
imgArea.Min.X++
}
UP:
for y := imgArea.Min.Y; y < imgArea.Max.Y; y++ {
for x := imgArea.Min.X; x < imgArea.Max.X; x++ {
if !isBlank(img.At(x, y)) {
break UP
}
}
imgArea.Min.Y++
}
RIGHT:
for x := imgArea.Max.X - 1; x >= imgArea.Min.X; x-- {
for y := imgArea.Min.Y; y < imgArea.Max.Y; y++ {
if !isBlank(img.At(x, y)) {
break RIGHT
}
}
imgArea.Max.X--
}
BOTTOM:
for y := imgArea.Max.Y - 1; y >= imgArea.Min.Y; y-- {
for x := imgArea.Min.X; x < imgArea.Max.X; x++ {
if !isBlank(img.At(x, y)) {
break BOTTOM
}
}
imgArea.Max.Y--
}
return img.SubImage(imgArea).(*image.Gray)
}
func Resize(img *image.Gray, w, h int) *image.Gray {
dim := img.Bounds()
origWidth := dim.Dx()
origHeight := dim.Dy()
if origWidth == 0 || origHeight == 0 {
newImg := image.NewGray(image.Rectangle{
image.Point{0, 0},
image.Point{w, h},
})
draw.Draw(newImg, newImg.Bounds(), image.NewUniform(color.White), newImg.Bounds().Min, draw.Src)
return newImg
}
width, height := origWidth*h/origHeight, origHeight*w/origWidth
if width > w {
width = w
}
if height > h {
height = h
}
newImg := image.NewGray(image.Rectangle{
Min: image.Point{0, 0},
Max: image.Point{width, height},
})
draw.BiLinear.Scale(newImg, newImg.Bounds(), img, img.Bounds(), draw.Src, nil)
return newImg
}
func Get(img *image.Gray, quality int) []byte {
b := bytes.NewBuffer([]byte{})
err := jpeg.Encode(b, img, &jpeg.Options{Quality: quality})
if err != nil {
panic(err)
}
return b.Bytes()
}
func Convert(reader io.ReadCloser, crop bool, w, h int, quality int, algo string) ([]byte, int, int) {
img := Load(reader, algo)
if crop {
img = CropMarging(img)
}
img = Resize(img, w, h)
return Get(img, quality), img.Bounds().Dx(), img.Bounds().Dy()
}

View File

@ -1,437 +0,0 @@
// Package converter Helper to parse and prepare options for go-comic-converter.
//
// It uses goflag with additional feature:
// - Keep original order
// - Support section
package converter
import (
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"reflect"
"regexp"
"runtime"
"slices"
"strings"
"time"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/utils"
)
type Converter struct {
Options *Options
Cmd *flag.FlagSet
order []order
isZeroValueErrs []error
startAt time.Time
}
// New Create a new parser
func New() *Converter {
o := NewOptions()
cmd := flag.NewFlagSet("go-comic-converter", flag.ExitOnError)
conv := &Converter{
Options: o,
Cmd: cmd,
order: make([]order, 0),
startAt: time.Now(),
}
var cmdOutput strings.Builder
cmd.SetOutput(&cmdOutput)
cmd.Usage = func() {
utils.Printf("Usage of %s:\n", filepath.Base(os.Args[0]))
for _, o := range conv.order {
switch v := o.(type) {
case orderSection:
utils.Printf("\n%s:\n", o.Value())
case orderName:
utils.Println(conv.Usage(v.isString, cmd.Lookup(v.Value())))
}
}
if cmdOutput.Len() > 0 {
utils.Printf("\nError: %s", cmdOutput.String())
}
}
return conv
}
// LoadConfig Load default options (config + default)
func (c *Converter) LoadConfig() error {
return c.Options.LoadConfig()
}
// AddSection Create a new section of config
func (c *Converter) AddSection(section string) {
c.order = append(c.order, orderSection{value: section})
}
// AddStringParam Add a string parameter
func (c *Converter) AddStringParam(p *string, name string, value string, usage string) {
c.Cmd.StringVar(p, name, value, usage)
c.order = append(c.order, orderName{value: name, isString: true})
}
// AddIntParam Add an integer parameter
func (c *Converter) AddIntParam(p *int, name string, value int, usage string) {
c.Cmd.IntVar(p, name, value, usage)
c.order = append(c.order, orderName{value: name})
}
// AddFloatParam Add an float parameter
func (c *Converter) AddFloatParam(p *float64, name string, value float64, usage string) {
c.Cmd.Float64Var(p, name, value, usage)
c.order = append(c.order, orderName{value: name})
}
// AddBoolParam Add a boolean parameter
func (c *Converter) AddBoolParam(p *bool, name string, value bool, usage string) {
c.Cmd.BoolVar(p, name, value, usage)
c.order = append(c.order, orderName{value: name})
}
// InitParse Initialize the parser with all section and parameter.
func (c *Converter) InitParse() {
c.AddSection("Output")
c.AddStringParam(&c.Options.Input, "input", "", "Source of comic to convert: directory, cbz, zip, cbr, rar, pdf")
c.AddStringParam(&c.Options.Output, "output", "", "Output of the EPUB (directory or EPUB): (default [INPUT].epub)")
c.AddStringParam(&c.Options.Author, "author", "GO Comic Converter", "Author of the EPUB")
c.AddStringParam(&c.Options.Title, "title", "", "Title of the EPUB")
c.AddSection("Config")
c.AddStringParam(&c.Options.Profile, "profile", c.Options.Profile, "Profile to use: \n"+c.Options.AvailableProfiles())
c.AddIntParam(&c.Options.Image.Quality, "quality", c.Options.Image.Quality, "Quality of the image")
c.AddBoolParam(&c.Options.Image.GrayScale, "grayscale", c.Options.Image.GrayScale, "Grayscale image. Ideal for eInk devices.")
c.AddIntParam(&c.Options.Image.GrayScaleMode, "grayscale-mode", c.Options.Image.GrayScaleMode, "Grayscale Mode\n0 = normal\n1 = average\n2 = luminance")
c.AddBoolParam(&c.Options.Image.Crop.Enabled, "crop", c.Options.Image.Crop.Enabled, "Crop images")
c.AddIntParam(&c.Options.Image.Crop.Left, "crop-ratio-left", c.Options.Image.Crop.Left, "Crop ratio left: ratio of pixels allow to be non blank while cutting on the left.")
c.AddIntParam(&c.Options.Image.Crop.Up, "crop-ratio-up", c.Options.Image.Crop.Up, "Crop ratio up: ratio of pixels allow to be non blank while cutting on the top.")
c.AddIntParam(&c.Options.Image.Crop.Right, "crop-ratio-right", c.Options.Image.Crop.Right, "Crop ratio right: ratio of pixels allow to be non blank while cutting on the right.")
c.AddIntParam(&c.Options.Image.Crop.Bottom, "crop-ratio-bottom", c.Options.Image.Crop.Bottom, "Crop ratio bottom: ratio of pixels allow to be non blank while cutting on the bottom.")
c.AddIntParam(&c.Options.Image.Crop.Limit, "crop-limit", c.Options.Image.Crop.Limit, "Crop limit: maximum number of cropping in percentage allowed. 0 mean unlimited.")
c.AddBoolParam(&c.Options.Image.Crop.SkipIfLimitReached, "crop-skip-if-limit-reached", c.Options.Image.Crop.SkipIfLimitReached, "Crop skip if limit reached.")
c.AddIntParam(&c.Options.Image.Brightness, "brightness", c.Options.Image.Brightness, "Brightness readjustment: between -100 and 100, > 0 lighter, < 0 darker")
c.AddIntParam(&c.Options.Image.Contrast, "contrast", c.Options.Image.Contrast, "Contrast readjustment: between -100 and 100, > 0 more contrast, < 0 less contrast")
c.AddBoolParam(&c.Options.Image.AutoContrast, "autocontrast", c.Options.Image.AutoContrast, "Improve contrast automatically")
c.AddBoolParam(&c.Options.Image.AutoRotate, "autorotate", c.Options.Image.AutoRotate, "Auto Rotate page when width > height")
c.AddBoolParam(&c.Options.Image.AutoSplitDoublePage, "autosplitdoublepage", c.Options.Image.AutoSplitDoublePage, "Auto Split double page when width > height")
c.AddBoolParam(&c.Options.Image.KeepDoublePageIfSplit, "keepdoublepageifsplit", c.Options.Image.KeepDoublePageIfSplit, "Keep the double page if split")
c.AddBoolParam(&c.Options.Image.KeepSplitDoublePageAspect, "keepsplitdoublepageaspect", c.Options.Image.KeepSplitDoublePageAspect, "Keep aspect of split part of a double page (best for landscape rendering)")
c.AddBoolParam(&c.Options.Image.NoBlankImage, "noblankimage", c.Options.Image.NoBlankImage, "Remove blank image")
c.AddBoolParam(&c.Options.Image.Manga, "manga", c.Options.Image.Manga, "Manga mode (right to left)")
c.AddBoolParam(&c.Options.Image.HasCover, "hascover", c.Options.Image.HasCover, "Has cover. Indicate if your comic have a cover. The first page will be used as a cover and include after the title.")
c.AddIntParam(&c.Options.LimitMb, "limitmb", c.Options.LimitMb, "Limit size of the EPUB: Default nolimit (0), Minimum 20")
c.AddBoolParam(&c.Options.StripFirstDirectoryFromToc, "strip", c.Options.StripFirstDirectoryFromToc, "Strip first directory from the TOC if only 1")
c.AddIntParam(&c.Options.SortPathMode, "sort", c.Options.SortPathMode, "Sort path mode\n0 = alpha for path and file\n1 = alphanumeric for path and alpha for file\n2 = alphanumeric for path and file")
c.AddStringParam(&c.Options.Image.View.Color.Foreground, "foreground-color", c.Options.Image.View.Color.Foreground, "Foreground color in hexadecimal format RGB. Black=000, White=FFF")
c.AddStringParam(&c.Options.Image.View.Color.Background, "background-color", c.Options.Image.View.Color.Background, "Background color in hexadecimal format RGB. Black=000, White=FFF, Light Gray=DDD, Dark Gray=777")
c.AddBoolParam(&c.Options.Image.Resize, "resize", c.Options.Image.Resize, "Reduce image size if exceed device size")
c.AddStringParam(&c.Options.Image.Format, "format", c.Options.Image.Format, "Format of output images: jpeg (lossy), png (lossless), copy (no processing)")
c.AddFloatParam(&c.Options.Image.View.AspectRatio, "aspect-ratio", c.Options.Image.View.AspectRatio, "Aspect ratio (height/width) of the output\n -1 = same as device\n 0 = same as source\n1.6 = amazon advice for kindle")
c.AddBoolParam(&c.Options.Image.View.PortraitOnly, "portrait-only", c.Options.Image.View.PortraitOnly, "Portrait only: force orientation to portrait only.")
c.AddIntParam(&c.Options.TitlePage, "titlepage", c.Options.TitlePage, "Title page\n0 = never\n1 = always\n2 = only if epub is split")
c.AddSection("Default config")
c.AddBoolParam(&c.Options.Show, "show", false, "Show your default parameters")
c.AddBoolParam(&c.Options.Save, "save", false, "Save your parameters as default")
c.AddBoolParam(&c.Options.Reset, "reset", false, "Reset your parameters to default")
c.AddSection("Shortcut")
c.AddBoolParam(&c.Options.Auto, "auto", false, "Activate all automatic options")
c.AddBoolParam(&c.Options.NoFilter, "nofilter", false, "Deactivate all filters")
c.AddBoolParam(&c.Options.MaxQuality, "maxquality", false, "Max quality: color png + noresize")
c.AddBoolParam(&c.Options.BestQuality, "bestquality", false, "Max quality: color jpg q100 + noresize")
c.AddBoolParam(&c.Options.GreatQuality, "greatquality", false, "Max quality: grayscale jpg q90 + noresize")
c.AddBoolParam(&c.Options.GoodQuality, "goodquality", false, "Max quality: grayscale jpg q90")
c.AddSection("Compatibility")
c.AddBoolParam(&c.Options.Image.AppleBookCompatibility, "applebookcompatibility", c.Options.Image.AppleBookCompatibility, "Apple book compatibility")
c.AddSection("Other")
c.AddIntParam(&c.Options.Workers, "workers", runtime.NumCPU(), "Number of workers")
c.AddBoolParam(&c.Options.Dry, "dry", false, "Dry run to show all options")
c.AddBoolParam(&c.Options.DryVerbose, "dry-verbose", false, "Display also sorted files after the TOC")
c.AddBoolParam(&c.Options.Quiet, "quiet", false, "Disable progress bar")
c.AddBoolParam(&c.Options.Json, "json", false, "Output progression and information in Json format")
c.AddBoolParam(&c.Options.Version, "version", false, "Show current and available version")
c.AddBoolParam(&c.Options.Help, "help", false, "Show this help message")
}
// Usage Customize version of FlagSet.PrintDefaults
func (c *Converter) Usage(isString bool, f *flag.Flag) string {
var b strings.Builder
b.WriteString(" -" + f.Name)
name, usage := flag.UnquoteUsage(f)
if len(name) > 0 {
b.WriteString(" ")
b.WriteString(name)
}
// Print the default value only if it differs to the zero value
// for this flag type.
if isZero, err := c.isZeroValue(f, f.DefValue); err != nil {
c.isZeroValueErrs = append(c.isZeroValueErrs, err)
} else if !isZero {
if isString {
b.WriteString(fmt.Sprintf(" (default %q)", f.DefValue))
} else {
b.WriteString(fmt.Sprintf(" (default %v)", f.DefValue))
}
}
// Boolean flags of one ASCII letter are so common we
// treat them specially, putting their usage on the same line.
if b.Len() <= 4 { // space, space, '-', 'x'.
b.WriteString("\t")
} else {
// Four spaces before the tab triggers good alignment
// for both 4- and 8-space tab stops.
b.WriteString("\n \t")
}
b.WriteString(strings.ReplaceAll(usage, "\n", "\n \t"))
return b.String()
}
// Taken from flag package as it is private and needed for usage.
//
// isZeroValue determines whether the string represents the zero
// value for a flag.
func (c *Converter) isZeroValue(f *flag.Flag, value string) (ok bool, err error) {
// Build a zero value of the flag's Value type, and see if the
// result of calling its String method equals the value passed in.
// This works unless the Value type is itself an interface type.
typ := reflect.TypeOf(f.Value)
var z reflect.Value
if typ.Kind() == reflect.Pointer {
z = reflect.New(typ.Elem())
} else {
z = reflect.Zero(typ)
}
// Catch panics calling the String method, which shouldn't prevent the
// usage message from being printed, but that we should report to the
// user so that they know to fix their code.
defer func() {
if e := recover(); e != nil {
if typ.Kind() == reflect.Pointer {
typ = typ.Elem()
}
err = fmt.Errorf("panic calling String method on zero %v for flag %s: %v", typ, f.Name, e)
}
}()
return value == z.Interface().(flag.Value).String(), nil
}
// Parse all parameters
func (c *Converter) Parse() {
if err := c.Cmd.Parse(os.Args[1:]); err != nil {
utils.Fatalf("cannot parse command line options: %v", err)
}
if c.Options.Help {
c.Cmd.Usage()
os.Exit(0)
}
if c.Options.Auto {
c.Options.Image.AutoContrast = true
c.Options.Image.AutoRotate = true
c.Options.Image.AutoSplitDoublePage = true
}
if c.Options.MaxQuality {
c.Options.Image.Format = "png"
c.Options.Image.GrayScale = false
c.Options.Image.Resize = false
} else if c.Options.BestQuality {
c.Options.Image.Format = "jpeg"
c.Options.Image.Quality = 100
c.Options.Image.GrayScale = false
c.Options.Image.Resize = false
} else if c.Options.GreatQuality {
c.Options.Image.Format = "jpeg"
c.Options.Image.Quality = 90
c.Options.Image.GrayScale = true
c.Options.Image.Resize = false
} else if c.Options.GoodQuality {
c.Options.Image.Format = "jpeg"
c.Options.Image.Quality = 90
c.Options.Image.GrayScale = true
c.Options.Image.Resize = true
}
if c.Options.NoFilter {
c.Options.Image.Crop.Enabled = false
c.Options.Image.Brightness = 0
c.Options.Image.Contrast = 0
c.Options.Image.AutoContrast = false
c.Options.Image.AutoRotate = false
c.Options.Image.NoBlankImage = false
c.Options.Image.Resize = false
}
if c.Options.Image.AppleBookCompatibility {
c.Options.Image.AutoSplitDoublePage = true
c.Options.Image.KeepDoublePageIfSplit = false
c.Options.Image.KeepSplitDoublePageAspect = true
}
if c.Options.Image.View.PortraitOnly {
c.Options.Image.KeepSplitDoublePageAspect = false
}
}
// Validate Check parameters
func (c *Converter) Validate() error {
// Check input
if c.Options.Input == "" {
return errors.New("missing input")
}
fi, err := os.Stat(c.Options.Input)
if err != nil {
return err
}
// Check Output
var defaultOutput string
inputBase := filepath.Clean(c.Options.Input)
if fi.IsDir() {
defaultOutput = inputBase + ".epub"
} else {
ext := filepath.Ext(inputBase)
defaultOutput = inputBase[0:len(inputBase)-len(ext)] + ".epub"
}
if c.Options.Output == "" {
c.Options.Output = defaultOutput
}
c.Options.Output = filepath.Clean(c.Options.Output)
if filepath.Ext(c.Options.Output) == ".epub" {
fo, err := os.Stat(filepath.Dir(c.Options.Output))
if err != nil {
return err
}
if !fo.IsDir() {
return errors.New("parent of the output is not a directory")
}
} else {
fo, err := os.Stat(c.Options.Output)
if err != nil {
return err
}
if !fo.IsDir() {
return errors.New("output must be an existing dir or end with .epub")
}
c.Options.Output = filepath.Join(
c.Options.Output,
filepath.Base(defaultOutput),
)
}
// Title
if c.Options.Title == "" {
ext := filepath.Ext(defaultOutput)
c.Options.Title = filepath.Base(defaultOutput[0 : len(defaultOutput)-len(ext)])
}
// Profile
if c.Options.Profile == "" {
return errors.New("profile missing")
}
if p := c.Options.GetProfile(); p == nil {
return fmt.Errorf("profile %q doesn't exists", c.Options.Profile)
}
// LimitMb
if c.Options.LimitMb < 20 && c.Options.LimitMb != 0 {
return errors.New("limitmb should be 0 or >= 20")
}
// Brightness
if c.Options.Image.Brightness < -100 || c.Options.Image.Brightness > 100 {
return errors.New("brightness should be between -100 and 100")
}
// Contrast
if c.Options.Image.Contrast < -100 || c.Options.Image.Contrast > 100 {
return errors.New("contrast should be between -100 and 100")
}
// SortPathMode
if c.Options.SortPathMode < 0 || c.Options.SortPathMode > 2 {
return errors.New("sort should be 0, 1 or 2")
}
// Color
colorRegex := regexp.MustCompile("^[0-9A-F]{3}$")
if !colorRegex.MatchString(c.Options.Image.View.Color.Foreground) {
return errors.New("foreground color must have color format in hexadecimal: [0-9A-F]{3}")
}
if !colorRegex.MatchString(c.Options.Image.View.Color.Background) {
return errors.New("background color must have color format in hexadecimal: [0-9A-F]{3}")
}
// Format
if !slices.Contains([]string{"jpeg", "png", "copy"}, c.Options.Image.Format) {
return errors.New("format should be jpeg, png or copy")
}
// Aspect Ratio
if c.Options.Image.View.AspectRatio < 0 && c.Options.Image.View.AspectRatio != -1 {
return errors.New("aspect ratio should be -1, 0 or > 0")
}
// Title Page
if c.Options.TitlePage < 0 || c.Options.TitlePage > 2 {
return errors.New("title page should be 0, 1 or 2")
}
// Grayscale Mode
if c.Options.Image.GrayScaleMode < 0 || c.Options.Image.GrayScaleMode > 2 {
return errors.New("grayscale mode should be 0, 1 or 2")
}
// crop
if c.Options.Image.Crop.Limit < 0 || c.Options.Image.Crop.Limit > 100 {
return errors.New("crop limit should be between 0 and 100")
}
return nil
}
// Fatal Helper to show usage, err and exit 1
func (c *Converter) Fatal(err error) {
c.Cmd.Usage()
utils.Fatalf("\nError: %s\n", err)
}
func (c *Converter) Stats() {
// Display elapse time and memory usage
elapse := time.Since(c.startAt).Round(time.Millisecond)
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
if c.Options.Json {
_ = json.NewEncoder(os.Stdout).Encode(map[string]any{
"type": "stats",
"data": map[string]any{
"elapse_ms": elapse.Milliseconds(),
"memory_usage_mb": mem.Sys / 1024 / 1024,
},
})
} else {
utils.Printf(
"Completed in %s, Memory usage %d Mb\n",
elapse,
mem.Sys/1024/1024,
)
}
}

View File

@ -1,251 +0,0 @@
// Package converter options manage options with default value from config.
package converter
import (
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/utils"
"github.com/celogeek/go-comic-converter/v3/pkg/epuboptions"
)
type Options struct {
epuboptions.EPUBOptions
// Config
Profile string `yaml:"profile" json:"profile"`
// Default Config
Show bool `yaml:"-" json:"-"`
Save bool `yaml:"-" json:"-"`
Reset bool `yaml:"-" json:"-"`
// Shortcut
Auto bool `yaml:"-" json:"-"`
NoFilter bool `yaml:"-" json:"-"`
MaxQuality bool `yaml:"-" json:"-"`
BestQuality bool `yaml:"-" json:"-"`
GreatQuality bool `yaml:"-" json:"-"`
GoodQuality bool `yaml:"-" json:"-"`
// Other
Version bool `yaml:"-" json:"-"`
Help bool `yaml:"-" json:"-"`
// Internal
profiles Profiles
}
// NewOptions Initialize default options.
func NewOptions() *Options {
return &Options{
Profile: "SR",
EPUBOptions: epuboptions.EPUBOptions{
Image: epuboptions.Image{
Quality: 85,
GrayScale: true,
Crop: epuboptions.Crop{
Enabled: true,
Left: 1,
Up: 1,
Right: 1,
Bottom: 3,
},
NoBlankImage: true,
HasCover: true,
KeepDoublePageIfSplit: true,
KeepSplitDoublePageAspect: true,
View: epuboptions.View{
Color: epuboptions.Color{
Foreground: "000",
Background: "FFF",
},
},
Resize: true,
Format: "jpeg",
},
TitlePage: 1,
SortPathMode: 1,
},
profiles: NewProfiles(),
}
}
func (o *Options) Header() string {
return "Go Comic Converter\n\nOptions:"
}
func (o *Options) String() string {
var b strings.Builder
b.WriteString(o.Header())
for _, v := range []struct {
K string
V any
}{
{"Input", o.Input},
{"Output", o.Output},
{"Author", o.Author},
{"Title", o.Title},
{"Workers", o.Workers},
} {
b.WriteString(fmt.Sprintf("\n %-32s: %v", v.K, v.V))
}
b.WriteString(o.ShowConfig())
b.WriteRune('\n')
return b.String()
}
// FileName Config file: ~/.go-comic-converter.yaml
func (o *Options) FileName() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".go-comic-converter.yaml")
}
// LoadConfig Load config files
func (o *Options) LoadConfig() error {
f, err := os.Open(o.FileName())
if err != nil {
return nil
}
defer func(f *os.File) {
_ = f.Close()
}(f)
err = yaml.NewDecoder(f).Decode(o)
if err != nil && err.Error() != "EOF" {
return err
}
return nil
}
// ShowConfig Get current settings for fields that can be saved
func (o *Options) ShowConfig() string {
var profileDesc string
profile := o.GetProfile()
if profile != nil {
profileDesc = profile.String()
}
sortpathmode := ""
switch o.SortPathMode {
case 0:
sortpathmode = "path=alpha, file=alpha"
case 1:
sortpathmode = "path=alphanumeric, file=alpha"
case 2:
sortpathmode = "path=alphanumeric, file=alphanumeric"
}
aspectRatio := "auto"
if o.Image.View.AspectRatio > 0 {
aspectRatio = "1:" + utils.FloatToString(o.Image.View.AspectRatio, 2)
} else if o.Image.View.AspectRatio < 0 {
if profile != nil {
aspectRatio = "1:" + utils.FloatToString(float64(profile.Height)/float64(profile.Width), 2) + " (device)"
} else {
aspectRatio = "1:?? (device)"
}
}
titlePage := ""
switch o.TitlePage {
case 0:
titlePage = "never"
case 1:
titlePage = "always"
case 2:
titlePage = "when epub is split"
}
grayscaleMode := "normal"
switch o.Image.GrayScaleMode {
case 1:
grayscaleMode = "average"
case 2:
grayscaleMode = "luminance"
}
var b strings.Builder
for _, v := range []struct {
Key string
Value any
Condition bool
}{
{"Profile", profileDesc, true},
{"Format", o.Image.Format, true},
{"Quality", o.Image.Quality, o.Image.Format == "jpeg"},
{"Grayscale", o.Image.GrayScale, o.Image.Format != "copy"},
{"Grayscale mode", grayscaleMode, o.Image.Format != "copy" && o.Image.GrayScale},
{"Crop", o.Image.Crop.Enabled, o.Image.Format != "copy"},
{"Crop ratio",
utils.IntToString(o.Image.Crop.Left) + " Left - " +
utils.IntToString(o.Image.Crop.Up) + " Up - " +
utils.IntToString(o.Image.Crop.Right) + " Right - " +
utils.IntToString(o.Image.Crop.Bottom) + " Bottom - " +
"Limit " + utils.IntToString(o.Image.Crop.Limit) + "% - " +
"Skip " + utils.BoolToString(o.Image.Crop.SkipIfLimitReached),
o.Image.Format != "copy" && o.Image.Crop.Enabled},
{"Brightness", o.Image.Brightness, o.Image.Format != "copy" && o.Image.Brightness != 0},
{"Contrast", o.Image.Contrast, o.Image.Format != "copy" && o.Image.Contrast != 0},
{"Auto contrast", o.Image.AutoContrast, o.Image.Format != "copy"},
{"Auto rotate", o.Image.AutoRotate, o.Image.Format != "copy"},
{"Auto split double page", o.Image.AutoSplitDoublePage, o.Image.Format != "copy" && (o.Image.View.PortraitOnly || !o.Image.AppleBookCompatibility)},
{"Keep double page if split", o.Image.KeepDoublePageIfSplit, o.Image.Format != "copy" && (o.Image.View.PortraitOnly || !o.Image.AppleBookCompatibility) && o.Image.AutoSplitDoublePage},
{"Keep split double page aspect", o.Image.KeepSplitDoublePageAspect, o.Image.Format != "copy" && (o.Image.View.PortraitOnly || !o.Image.AppleBookCompatibility) && o.Image.AutoSplitDoublePage},
{"No blank image", o.Image.NoBlankImage, o.Image.Format != "copy"},
{"Manga", o.Image.Manga, true},
{"Has cover", o.Image.HasCover, true},
{"Limit", utils.IntToString(o.LimitMb) + " Mb", o.LimitMb != 0},
{"Strip first directory from toc", o.StripFirstDirectoryFromToc, true},
{"Sort path mode", sortpathmode, true},
{"Foreground color", "#" + o.Image.View.Color.Foreground, true},
{"Background color", "#" + o.Image.View.Color.Background, true},
{"Resize", o.Image.Resize, o.Image.Format != "copy"},
{"Aspect ratio", aspectRatio, true},
{"Portrait only", o.Image.View.PortraitOnly, true},
{"Title page", titlePage, true},
{"Apple book compatibility", o.Image.AppleBookCompatibility, !o.Image.View.PortraitOnly},
} {
if v.Condition {
b.WriteString(fmt.Sprintf("\n %-32s: %v", v.Key, v.Value))
}
}
return b.String()
}
// ResetConfig reset all settings to default value
func (o *Options) ResetConfig() error {
if err := NewOptions().SaveConfig(); err != nil {
return err
}
return o.LoadConfig()
}
// SaveConfig save all current settings as default value
func (o *Options) SaveConfig() error {
f, err := os.Create(o.FileName())
if err != nil {
return err
}
defer func(f *os.File) {
_ = f.Close()
}(f)
return yaml.NewEncoder(f).Encode(o)
}
// GetProfile shortcut to get current profile
func (o *Options) GetProfile() *Profile {
if p, ok := o.profiles[o.Profile]; ok {
return &p
}
return nil
}
// AvailableProfiles all available profiles
func (o *Options) AvailableProfiles() string {
return o.profiles.String()
}

View File

@ -1,27 +0,0 @@
package converter
// Name or Section
type order interface {
Value() string
}
// Section
type orderSection struct {
value string
}
func (s orderSection) Value() string {
return s.value
}
// Name
//
// isString is used to quote the default value.
type orderName struct {
value string
isString bool
}
func (s orderName) Value() string {
return s.value
}

View File

@ -1,77 +0,0 @@
// Package converter profiles manage supported profiles for go-comic-converter.
package converter
import (
"fmt"
"strings"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/utils"
)
type Profile struct {
Code string `json:"code"`
Description string `json:"description"`
Width int `json:"width"`
Height int `json:"height"`
}
func (p Profile) String() string {
return p.Code + " - " + p.Description + " - " + utils.IntToString(p.Width) + "x" + utils.IntToString(p.Height)
}
type Profiles map[string]Profile
// NewProfiles Initialize list of all supported profiles.
func NewProfiles() Profiles {
res := make(Profiles)
for _, r := range []Profile{
// High Resolution for Tablet
{"HR", "High Resolution", 2400, 3840},
{"SR", "Standard Resolution", 1200, 1920},
//Kindle
{"K1", "Kindle 1", 600, 670},
{"K11", "Kindle 11", 1072, 1448},
{"K2", "Kindle 2", 600, 670},
{"K34", "Kindle Keyboard/Touch", 600, 800},
{"K578", "Kindle", 600, 800},
{"KDX", "Kindle DX/DXG", 824, 1000},
{"KPW", "Kindle Paperwhite 1/2", 758, 1024},
{"KV", "Kindle Paperwhite 3/4/Voyage/Oasis", 1072, 1448},
{"KPW5", "Kindle Paperwhite 5/Signature Edition", 1236, 1648},
{"KO", "Kindle Oasis 2/3", 1264, 1680},
{"KS", "Kindle Scribe", 1860, 2480},
// Kobo
{"KoMT", "Kobo Mini/Touch", 600, 800},
{"KoG", "Kobo Glo", 768, 1024},
{"KoGHD", "Kobo Glo HD", 1072, 1448},
{"KoA", "Kobo Aura", 758, 1024},
{"KoAHD", "Kobo Aura HD", 1080, 1440},
{"KoAH2O", "Kobo Aura H2O", 1080, 1430},
{"KoAO", "Kobo Aura ONE", 1404, 1872},
{"KoN", "Kobo Nia", 758, 1024},
{"KoC", "Kobo Clara HD/Kobo Clara 2E", 1072, 1448},
{"KoL", "Kobo Libra H2O/Kobo Libra 2", 1264, 1680},
{"KoF", "Kobo Forma", 1440, 1920},
{"KoS", "Kobo Sage", 1440, 1920},
{"KoE", "Kobo Elipsa", 1404, 1872},
// reMarkable
{"RM1", "reMarkable 1", 1404, 1872},
{"RM2", "reMarkable 2", 1404, 1872},
} {
res[r.Code] = r
}
return res
}
func (p Profiles) String() string {
s := make([]string, 0)
for _, v := range p {
s = append(s, fmt.Sprintf(
" - %-7s - %4d x %-4d - %s",
v.Code,
v.Width, v.Height,
v.Description,
))
}
return strings.Join(s, "\n")
}

View File

@ -1,130 +0,0 @@
// Package epubimage EPUBImage helpers to transform image.
package epubimage
import (
"image"
"strings"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/utils"
)
type EPUBImage struct {
Id int
Part int
Raw image.Image
Width int
Height int
IsBlank bool
DoublePage bool
Path string
Name string
Position string
Format string
OriginalAspectRatio float64
Error error
}
// SpaceKey key name of the blank page after the image
func (i EPUBImage) SpaceKey() string {
return "space_" + utils.IntToString(i.Id)
}
// SpacePath path of the blank page
func (i EPUBImage) SpacePath() string {
return "Text/" + i.SpaceKey() + ".xhtml"
}
// EPUBSpacePath path of the blank page into the EPUB
func (i EPUBImage) EPUBSpacePath() string {
return "OEBPS/" + i.SpacePath()
}
func (i EPUBImage) PartKey() string {
return utils.IntToString(i.Id) + "_p" + utils.IntToString(i.Part)
}
// PageKey key for page
func (i EPUBImage) PageKey() string {
return "page_" + i.PartKey()
}
// PagePath page path linked to the image
func (i EPUBImage) PagePath() string {
return "Text/" + i.PageKey() + ".xhtml"
}
// EPUBPagePath page path into the EPUB
func (i EPUBImage) EPUBPagePath() string {
return "OEBPS/" + i.PagePath()
}
// ImgKey key for image
func (i EPUBImage) ImgKey() string {
return "img_" + i.PartKey()
}
// ImgPath image path
func (i EPUBImage) ImgPath() string {
return "Images/" + i.ImgKey() + "." + i.Format
}
// EPUBImgPath image path into the EPUB
func (i EPUBImage) EPUBImgPath() string {
return "OEBPS/" + i.ImgPath()
}
// MediaType of the epub image
func (i EPUBImage) MediaType() string {
return "image/" + i.Format
}
// ImgStyle style to apply to the image.
//
// center by default.
// align to left or right if it's part of the split double page.
func (i EPUBImage) ImgStyle(viewWidth, viewHeight int, align string) string {
relWidth, relHeight := i.RelSize(viewWidth, viewHeight)
marginW, marginH := float64(viewWidth-relWidth)/2, float64(viewHeight-relHeight)/2
style := make([]string, 0, 4)
style = append(style, "width:"+utils.IntToString(relWidth)+"px")
style = append(style, "height:"+utils.IntToString(relHeight)+"px")
style = append(style, "top:"+utils.FloatToString(marginH*100/float64(viewHeight), 2)+"%")
if align == "" {
switch i.Position {
case "rendition:page-spread-left":
style = append(style, "right:0")
case "rendition:page-spread-right":
style = append(style, "left:0")
default:
style = append(style, "left:"+utils.FloatToString(marginW*100/float64(viewWidth), 2)+"%")
}
} else {
style = append(style, align)
}
return strings.Join(style, "; ")
}
func (i EPUBImage) RelSize(viewWidth, viewHeight int) (relWidth, relHeight int) {
w, h := viewWidth, viewHeight
srcw, srch := i.Width, i.Height
if w <= 0 || h <= 0 || srcw <= 0 || srch <= 0 {
return
}
wratio := float64(srcw) / float64(w)
hratio := float64(srch) / float64(h)
if wratio > hratio {
relWidth = w
relHeight = int(float64(srch)/wratio + 0.5)
} else {
relHeight = h
relWidth = int(float64(srcw)/hratio + 0.5)
}
return
}

View File

@ -1,90 +0,0 @@
package epubimagefilters
import (
"image"
"image/color"
"image/draw"
"github.com/disintegration/gift"
)
// AutoContrast Automatically improve contrast
func AutoContrast() gift.Filter {
return autocontrast{}
}
type autocontrast struct {
}
// compute the color number between 0 and 1 that hold half of the pixel
func (f autocontrast) mean(src image.Image) float32 {
bucket := map[int]int{}
for x := src.Bounds().Min.X; x < src.Bounds().Max.X; x++ {
for y := src.Bounds().Min.Y; y < src.Bounds().Max.Y; y++ {
v, _, _, _ := color.GrayModel.Convert(src.At(x, y)).RGBA()
bucket[int(v)]++
}
}
// calculate color idx
var colorIdx int
{
// limit to half of the pixel
limit := src.Bounds().Dx() * src.Bounds().Dy() / 2
// loop on all color from 0 to 65536
for colorIdx := range 1 << 16 {
if limit-bucket[colorIdx] < 0 {
break
}
limit -= bucket[colorIdx]
}
}
// return the color idx between 0 and 1
return float32(colorIdx) / (1 << 16)
}
// ensure value stay into 0 to 1 bound
func (f autocontrast) cap(v float32) float32 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
// power of 2 for float32
func (f autocontrast) pow2(v float32) float32 {
return v * v
}
// Draw into the dst after applying the filter
func (f autocontrast) Draw(dst draw.Image, src image.Image, options *gift.Options) {
// half of the pixel has this color idx
colorMean := f.mean(src)
// if colorMean > 0.5, it means the color is mostly clear
// in that case we will add a lot more darkness other light
// compute dark factor
d := f.pow2(colorMean)
// compute light factor
l := f.pow2(1 - colorMean)
gift.ColorFunc(func(r0, g0, b0, a0 float32) (r float32, g float32, b float32, a float32) {
// convert to gray color the source RGB
y := 0.299*r0 + 0.587*g0 + 0.114*b0
// compute a curve from dark and light factor applying to the color
c := (1 - d) + (d+l)*y
// applying the ratio
return f.cap(r0 * c), f.cap(g0 * c), f.cap(b0 * c), a0
}).Draw(dst, src, options)
}
// Bounds calculates the appropriate bounds of an image after applying the filter.
func (autocontrast) Bounds(srcBounds image.Rectangle) (dstBounds image.Rectangle) {
dstBounds = srcBounds
return
}

View File

@ -1,123 +0,0 @@
package epubimagefilters
import (
"image"
"image/color"
"github.com/disintegration/gift"
)
// AutoCrop Lookup for margin and crop
func AutoCrop(img image.Image, bounds image.Rectangle, cutRatioLeft, cutRatioUp, cutRatioRight, cutRatioBottom int, limit int, skipIfLimitReached bool) gift.Filter {
return gift.Crop(
findMargin(img, bounds, cutRatioOptions{cutRatioLeft, cutRatioUp, cutRatioRight, cutRatioBottom}, limit, skipIfLimitReached),
)
}
// check if the color is blank enough
func colorIsBlank(c color.Color) bool {
g := color.GrayModel.Convert(c).(color.Gray)
return g.Y >= 0xe0
}
// lookup for margin (blank) around the image
type cutRatioOptions struct {
Left, Up, Right, Bottom int
}
func findMargin(img image.Image, bounds image.Rectangle, cutRatio cutRatioOptions, limit int, skipIfLimitReached bool) image.Rectangle {
imgArea := bounds
LEFT:
for x := imgArea.Min.X; x < imgArea.Max.X; x++ {
allowNonBlank := imgArea.Dy() * cutRatio.Left / 100
for y := imgArea.Min.Y; y < imgArea.Max.Y; y++ {
if !colorIsBlank(img.At(x, y)) {
allowNonBlank--
if allowNonBlank <= 0 {
break LEFT
}
}
}
imgArea.Min.X++
}
UP:
for y := imgArea.Min.Y; y < imgArea.Max.Y; y++ {
allowNonBlank := imgArea.Dx() * cutRatio.Up / 100
for x := imgArea.Min.X; x < imgArea.Max.X; x++ {
if !colorIsBlank(img.At(x, y)) {
allowNonBlank--
if allowNonBlank <= 0 {
break UP
}
}
}
imgArea.Min.Y++
}
RIGHT:
for x := imgArea.Max.X - 1; x >= imgArea.Min.X; x-- {
allowNonBlank := imgArea.Dy() * cutRatio.Right / 100
for y := imgArea.Min.Y; y < imgArea.Max.Y; y++ {
if !colorIsBlank(img.At(x, y)) {
allowNonBlank--
if allowNonBlank <= 0 {
break RIGHT
}
}
}
imgArea.Max.X--
}
BOTTOM:
for y := imgArea.Max.Y - 1; y >= imgArea.Min.Y; y-- {
allowNonBlank := imgArea.Dx() * cutRatio.Bottom / 100
for x := imgArea.Min.X; x < imgArea.Max.X; x++ {
if !colorIsBlank(img.At(x, y)) {
allowNonBlank--
if allowNonBlank <= 0 {
break BOTTOM
}
}
}
imgArea.Max.Y--
}
// no limit or blankImage
if limit == 0 || imgArea.Dx() == 0 || imgArea.Dy() == 0 {
return imgArea
}
exceedX, exceedY := limitExceed(bounds, imgArea, limit)
if skipIfLimitReached && (exceedX > 0 || exceedY > 0) {
return bounds
}
imgArea.Min.X, imgArea.Max.X = correctLine(imgArea.Min.X, imgArea.Max.X, bounds.Min.X, bounds.Max.X, exceedX)
imgArea.Min.Y, imgArea.Max.Y = correctLine(imgArea.Min.Y, imgArea.Max.Y, bounds.Min.Y, bounds.Max.Y, exceedY)
return imgArea
}
func limitExceed(bounds, newBounds image.Rectangle, limit int) (int, int) {
return bounds.Dx() - newBounds.Dx() - bounds.Dx()*limit/100, bounds.Dy() - newBounds.Dy() - bounds.Dy()*limit/100
}
func correctLine(min, max, bMin, bMax, exceed int) (int, int) {
if exceed <= 0 {
return min, max
}
min -= exceed / 2
max += exceed / 2
if min < bMin {
max += bMin - min
min = bMin
}
if max > bMax {
min -= max - bMax
max = bMax
}
return min, max
}

View File

@ -1,98 +0,0 @@
package epubimagefilters
import (
"image"
"image/draw"
"github.com/disintegration/gift"
"github.com/golang/freetype"
"github.com/golang/freetype/truetype"
"golang.org/x/image/font"
"golang.org/x/image/font/gofont/gomonobold"
)
// CoverTitle Create a title with the cover image
func CoverTitle(title string, align string, pctWidth int, pctMargin int, maxFontSize int, borderSize int) gift.Filter {
return coverTitle{title, align, pctWidth, pctMargin, maxFontSize, borderSize}
}
type coverTitle struct {
title string
align string
pctWidth int
pctMargin int
maxFontSize int
borderSize int
}
// Bounds size is the same as source
func (p coverTitle) Bounds(srcBounds image.Rectangle) (dstBounds image.Rectangle) {
return srcBounds
}
// Draw blur the src image, and create a box with the title in the middle
func (p coverTitle) Draw(dst draw.Image, src image.Image, _ *gift.Options) {
draw.Draw(dst, dst.Bounds(), src, src.Bounds().Min, draw.Src)
if p.title == "" {
return
}
srcWidth, srcHeight := src.Bounds().Dx(), src.Bounds().Dy()
// Calculate size of title
f, _ := truetype.Parse(gomonobold.TTF)
var fontSize, textWidth, textHeight int
for fontSize = p.maxFontSize; fontSize >= 12; fontSize -= 1 {
face := truetype.NewFace(f, &truetype.Options{Size: float64(fontSize), DPI: 72})
textWidth = font.MeasureString(face, p.title).Ceil()
textHeight = face.Metrics().Ascent.Ceil() + face.Metrics().Descent.Ceil()
if textWidth+2*p.borderSize < srcWidth*p.pctWidth/100 && 3*textHeight+2*p.borderSize < srcHeight {
break
}
}
// Draw rectangle in the middle of the image
marginSize := fontSize * p.pctMargin / 100
var textPosStart, textPosEnd int
if p.align == "bottom" {
textPosStart = srcHeight - textHeight - p.borderSize - marginSize
textPosEnd = srcHeight - p.borderSize - marginSize
} else {
textPosStart = srcHeight/2 - textHeight/2
textPosEnd = srcHeight/2 + textHeight/2
}
borderArea := image.Rect((srcWidth-(srcWidth*p.pctWidth/100))/2, textPosStart-p.borderSize-marginSize, (srcWidth+(srcWidth*p.pctWidth/100))/2, textPosEnd+p.borderSize+marginSize)
textArea := image.Rect(borderArea.Bounds().Min.X+p.borderSize, textPosStart-marginSize, borderArea.Bounds().Max.X-p.borderSize, textPosEnd+marginSize)
draw.Draw(
dst,
borderArea,
image.Black,
borderArea.Min,
draw.Src,
)
draw.Draw(
dst,
textArea,
image.White,
textArea.Min,
draw.Src,
)
// Draw text
c := freetype.NewContext()
c.SetDPI(72)
c.SetFontSize(float64(fontSize))
c.SetFont(f)
c.SetClip(textArea)
c.SetDst(dst)
c.SetSrc(image.Black)
textLeft := textArea.Min.X + textArea.Dx()/2 - textWidth/2
if textLeft < textArea.Min.X {
textLeft = textArea.Min.X
}
textTop := textArea.Min.Y + textArea.Dy()/2 + textHeight/4
_, _ = c.DrawString(p.title, freetype.Pt(textLeft, textTop))
}

View File

@ -1,38 +0,0 @@
package epubimagefilters
import (
"image"
"image/draw"
"github.com/disintegration/gift"
)
// CropSplitDoublePage Cut a double page in 2 part: left and right.
//
// This will cut in the middle of the page.
func CropSplitDoublePage(right bool) gift.Filter {
return cropSplitDoublePage{right}
}
type cropSplitDoublePage struct {
right bool
}
func (p cropSplitDoublePage) Bounds(srcBounds image.Rectangle) (dstBounds image.Rectangle) {
if p.right {
dstBounds = image.Rect(
srcBounds.Max.X/2, srcBounds.Min.Y,
srcBounds.Max.X, srcBounds.Max.Y,
)
} else {
dstBounds = image.Rect(
srcBounds.Min.X, srcBounds.Min.Y,
srcBounds.Max.X/2, srcBounds.Max.Y,
)
}
return
}
func (p cropSplitDoublePage) Draw(dst draw.Image, src image.Image, options *gift.Options) {
gift.Crop(dst.Bounds()).Draw(dst, src, options)
}

View File

@ -1,36 +0,0 @@
package epubimagefilters
import (
"image"
"image/color"
"image/draw"
"github.com/disintegration/gift"
)
// Pixel Generate a blank pixel 1x1, if the size of the image is 0x0.
//
// An image 0x0 is not a valid image, and failed to read.
func Pixel() gift.Filter {
return pixel{}
}
type pixel struct {
}
func (p pixel) Bounds(srcBounds image.Rectangle) (dstBounds image.Rectangle) {
if srcBounds.Dx() == 0 || srcBounds.Dy() == 0 {
dstBounds = image.Rect(0, 0, 1, 1)
} else {
dstBounds = srcBounds
}
return
}
func (p pixel) Draw(dst draw.Image, src image.Image, _ *gift.Options) {
if dst.Bounds().Dx() == 1 && dst.Bounds().Dy() == 1 {
dst.Set(0, 0, color.White)
return
}
draw.Draw(dst, dst.Bounds(), src, src.Bounds().Min, draw.Src)
}

View File

@ -1,432 +0,0 @@
package epubimagepassthrough
import (
"archive/zip"
"bytes"
"errors"
"fmt"
"image"
"image/jpeg"
"image/png"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"github.com/nwaples/rardecode/v2"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/epubimage"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/epubimageprocessor"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/epubprogress"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/epubzip"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/sortpath"
"github.com/celogeek/go-comic-converter/v3/pkg/epuboptions"
)
type ePUBImagePassthrough struct {
epuboptions.EPUBOptions
}
func (e ePUBImagePassthrough) Load() (images []epubimage.EPUBImage, err error) {
fi, err := os.Stat(e.Input)
if err != nil {
return
}
if fi.IsDir() {
return e.loadDir()
} else {
switch ext := strings.ToLower(filepath.Ext(e.Input)); ext {
case ".cbz", ".zip":
return e.loadCbz()
case ".cbr", ".rar":
return e.loadCbr()
default:
return nil, fmt.Errorf("unknown file format (%s): support .cbz, .zip, .cbr, .rar", ext)
}
}
}
func (e ePUBImagePassthrough) CoverTitleData(o epubimageprocessor.CoverTitleDataOptions) (epubzip.Image, error) {
return epubimageprocessor.New(e.EPUBOptions).CoverTitleData(o)
}
var errNoImagesFound = errors.New("no images found")
func New(o epuboptions.EPUBOptions) epubimageprocessor.EPUBImageProcessor {
return ePUBImagePassthrough{o}
}
func (e ePUBImagePassthrough) loadDir() (images []epubimage.EPUBImage, err error) {
imagesPath := make([]string, 0)
input := filepath.Clean(e.Input)
err = filepath.WalkDir(input, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && e.isSupportedImage(path) {
imagesPath = append(imagesPath, path)
}
return nil
})
if err != nil {
return
}
if len(imagesPath) == 0 {
err = errNoImagesFound
return
}
sort.Sort(sortpath.By(imagesPath, e.SortPathMode))
var imgStorage epubzip.StorageImageWriter
imgStorage, err = epubzip.NewStorageImageWriter(e.ImgStorage(), e.Image.Format)
if err != nil {
return
}
defer imgStorage.Close()
// processing
bar := epubprogress.New(epubprogress.Options{
Quiet: e.Quiet,
Json: e.Json,
Max: len(imagesPath),
Description: "Copying",
CurrentJob: 1,
TotalJob: 2,
})
defer bar.Close()
for i, imgPath := range imagesPath {
var img epubimage.EPUBImage
img, err = e.copyRawDataToStorage(
imgStorage,
func() ([]byte, error) {
f, err := os.Open(imgPath)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(f)
},
i,
input,
imgPath,
)
if err != nil {
return
}
images = append(images, img)
_ = bar.Add(1)
}
if len(images) == 0 {
err = errNoImagesFound
}
return
}
func (e ePUBImagePassthrough) loadCbz() (images []epubimage.EPUBImage, err error) {
images = make([]epubimage.EPUBImage, 0)
input := filepath.Clean(e.Input)
r, err := zip.OpenReader(input)
if err != nil {
return
}
defer r.Close()
imagesZip := make([]*zip.File, 0)
for _, f := range r.File {
if !f.FileInfo().IsDir() && e.isSupportedImage(f.Name) {
imagesZip = append(imagesZip, f)
}
}
if len(imagesZip) == 0 {
err = errNoImagesFound
return
}
var names []string
for _, img := range imagesZip {
names = append(names, img.Name)
}
sort.Sort(sortpath.By(names, e.SortPathMode))
indexedNames := make(map[string]int)
for i, name := range names {
indexedNames[name] = i
}
var imgStorage epubzip.StorageImageWriter
imgStorage, err = epubzip.NewStorageImageWriter(e.ImgStorage(), e.Image.Format)
if err != nil {
return
}
defer imgStorage.Close()
// processing
bar := epubprogress.New(epubprogress.Options{
Quiet: e.Quiet,
Json: e.Json,
Max: len(imagesZip),
Description: "Copying",
CurrentJob: 1,
TotalJob: 2,
})
defer bar.Close()
for _, imgZip := range imagesZip {
if _, ok := indexedNames[imgZip.Name]; !ok {
continue
}
var img epubimage.EPUBImage
img, err = e.copyRawDataToStorage(
imgStorage,
func() ([]byte, error) {
f, err := imgZip.Open()
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(f)
},
indexedNames[imgZip.Name],
"",
imgZip.Name,
)
if err != nil {
return
}
images = append(images, img)
_ = bar.Add(1)
}
if len(images) == 0 {
err = errNoImagesFound
}
return
}
func (e ePUBImagePassthrough) loadCbr() (images []epubimage.EPUBImage, err error) {
images = make([]epubimage.EPUBImage, 0)
var isSolid bool
files, err := rardecode.List(e.Input)
if err != nil {
return
}
names := make([]string, 0)
for _, f := range files {
if !f.IsDir && e.isSupportedImage(f.Name) {
if f.Solid {
isSolid = true
}
names = append(names, f.Name)
}
}
if len(names) == 0 {
err = errNoImagesFound
return
}
sort.Sort(sortpath.By(names, e.SortPathMode))
indexedNames := make(map[string]int)
for i, name := range names {
indexedNames[name] = i
}
var imgStorage epubzip.StorageImageWriter
imgStorage, err = epubzip.NewStorageImageWriter(e.ImgStorage(), e.Image.Format)
if err != nil {
return
}
defer imgStorage.Close()
// processing
bar := epubprogress.New(epubprogress.Options{
Quiet: e.Quiet,
Json: e.Json,
Max: len(names),
Description: "Copying",
CurrentJob: 1,
TotalJob: 2,
})
defer bar.Close()
if isSolid {
var r *rardecode.ReadCloser
r, err = rardecode.OpenReader(e.Input)
if err != nil {
return
}
defer r.Close()
for {
f, rerr := r.Next()
if rerr != nil {
if rerr == io.EOF {
break
}
err = rerr
return
}
if _, ok := indexedNames[f.Name]; !ok {
continue
}
var img epubimage.EPUBImage
img, err = e.copyRawDataToStorage(
imgStorage,
func() ([]byte, error) {
return io.ReadAll(r)
},
indexedNames[f.Name],
"",
f.Name,
)
if err != nil {
return
}
images = append(images, img)
_ = bar.Add(1)
}
} else {
for _, file := range files {
if i, ok := indexedNames[file.Name]; ok {
var img epubimage.EPUBImage
img, err = e.copyRawDataToStorage(
imgStorage,
func() ([]byte, error) {
f, err := file.Open()
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(f)
},
i,
"",
file.Name,
)
if err != nil {
return
}
images = append(images, img)
_ = bar.Add(1)
}
}
}
if len(images) == 0 {
err = errNoImagesFound
}
return
}
func (e ePUBImagePassthrough) isSupportedImage(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".jpg", ".jpeg", ".png":
{
return !strings.HasPrefix(filepath.Base(path), ".")
}
}
return false
}
func (e ePUBImagePassthrough) copyRawDataToStorage(
imgStorage epubzip.StorageImageWriter,
getData func() ([]byte, error),
id int,
dirname string,
filename string,
) (img epubimage.EPUBImage, err error) {
var uncompressedData []byte
uncompressedData, err = getData()
if err != nil {
return
}
p, fn := filepath.Split(filepath.Clean(filename))
if p == dirname {
p = ""
} else {
p = p[len(dirname)+1:]
}
var (
format string
decodeConfig func(r io.Reader) (image.Config, error)
decode func(r io.Reader) (image.Image, error)
)
switch filepath.Ext(fn) {
case ".png":
format = "png"
decodeConfig = png.DecodeConfig
decode = png.Decode
case ".jpg", ".jpeg":
format = "jpeg"
decodeConfig = jpeg.DecodeConfig
decode = jpeg.Decode
}
var config image.Config
config, err = decodeConfig(bytes.NewReader(uncompressedData))
if err != nil {
return
}
var rawImage image.Image
if id == 0 {
rawImage, err = decode(bytes.NewReader(uncompressedData))
if err != nil {
return
}
}
img = epubimage.EPUBImage{
Id: id,
Part: 0,
Raw: rawImage,
Width: config.Width,
Height: config.Height,
IsBlank: false,
DoublePage: config.Width > config.Height,
Path: p,
Name: fn,
Format: format,
OriginalAspectRatio: float64(config.Height) / float64(config.Width),
}
err = imgStorage.AddRaw(img.EPUBImgPath(), uncompressedData)
return
}

View File

@ -1,431 +0,0 @@
package epubimageprocessor
import (
"archive/zip"
"bytes"
"errors"
"fmt"
"image"
"image/color"
_ "image/jpeg"
_ "image/png"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"golang.org/x/image/font/gofont/gomonobold"
_ "golang.org/x/image/tiff"
_ "golang.org/x/image/webp"
"github.com/fogleman/gg"
"github.com/golang/freetype/truetype"
"github.com/nwaples/rardecode/v2"
pdfimage "github.com/raff/pdfreader/image"
"github.com/raff/pdfreader/pdfread"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/sortpath"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/utils"
)
type task struct {
Id int
Image image.Image
Path string
Name string
Error error
}
var errNoImagesFound = errors.New("no images found")
// only accept jpg, png and webp as source file
func (e ePUBImageProcessor) isSupportedImage(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".jpg", ".jpeg", ".png", ".webp", ".tiff":
{
return !strings.HasPrefix(filepath.Base(path), ".")
}
}
return false
}
// load images from input
func (e ePUBImageProcessor) load() (totalImages int, output chan task, err error) {
fi, err := os.Stat(e.Input)
if err != nil {
return
}
// get all images though a channel of bytes
if fi.IsDir() {
return e.loadDir()
} else {
switch ext := strings.ToLower(filepath.Ext(e.Input)); ext {
case ".cbz", ".zip":
return e.loadCbz()
case ".cbr", ".rar":
return e.loadCbr()
case ".pdf":
return e.loadPdf()
default:
err = fmt.Errorf("unknown file format (%s): support .cbz, .zip, .cbr, .rar, .pdf", ext)
return
}
}
}
func (e ePUBImageProcessor) corruptedImage(path, name string) image.Image {
var w, h float64 = 1200, 1920
f, _ := truetype.Parse(gomonobold.TTF)
face := truetype.NewFace(f, &truetype.Options{Size: 64, DPI: 72})
txt := name
if path != "" {
txt += "\nin " + filepath.Clean(path)
}
txt += "\nis corrupted!"
g := gg.NewContext(int(w), int(h))
g.SetColor(color.White)
g.Clear()
g.SetColor(color.Black)
g.DrawRoundedRectangle(0, 0, w, h, 0.5)
g.SetLineWidth(6)
g.Stroke()
g.DrawRoundedRectangle(0, 0, 480, 640, 0.5)
g.SetFontFace(face)
g.DrawStringWrapped(txt, w/2, h/2, 0.5, 0.5, 640, 1.5, gg.AlignCenter)
return g.Image()
}
// load a directory of images
func (e ePUBImageProcessor) loadDir() (totalImages int, output chan task, err error) {
images := make([]string, 0)
input := filepath.Clean(e.Input)
err = filepath.WalkDir(input, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && e.isSupportedImage(path) {
images = append(images, path)
}
return nil
})
if err != nil {
return
}
totalImages = len(images)
if totalImages == 0 {
err = errNoImagesFound
return
}
sort.Sort(sortpath.By(images, e.SortPathMode))
// Queue all file with id
type job struct {
Id int
Path string
}
jobs := make(chan job)
go func() {
defer close(jobs)
for i, path := range images {
jobs <- job{i, path}
}
}()
// read in parallel and get an image
output = make(chan task, e.Workers)
wg := &sync.WaitGroup{}
for range e.WorkersRatio(50) {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
var img image.Image
var err error
if !e.Dry {
var f *os.File
f, err = os.Open(job.Path)
if err == nil {
img, _, err = image.Decode(f)
_ = f.Close()
}
}
p, fn := filepath.Split(job.Path)
if p == input {
p = ""
} else {
p = p[len(input)+1:]
}
if err != nil {
img = e.corruptedImage(p, fn)
}
output <- task{
Id: job.Id,
Image: img,
Path: p,
Name: fn,
Error: err,
}
}
}()
}
// wait all done and close
go func() {
wg.Wait()
close(output)
}()
return
}
// load a zip file that include images
func (e ePUBImageProcessor) loadCbz() (totalImages int, output chan task, err error) {
r, err := zip.OpenReader(e.Input)
if err != nil {
return
}
images := make([]*zip.File, 0)
for _, f := range r.File {
if !f.FileInfo().IsDir() && e.isSupportedImage(f.Name) {
images = append(images, f)
}
}
totalImages = len(images)
if totalImages == 0 {
_ = r.Close()
err = errNoImagesFound
return
}
var names []string
for _, img := range images {
names = append(names, img.Name)
}
sort.Sort(sortpath.By(names, e.SortPathMode))
indexedNames := make(map[string]int)
for i, name := range names {
indexedNames[name] = i
}
type job struct {
Id int
F *zip.File
}
jobs := make(chan job)
go func() {
defer close(jobs)
for _, img := range images {
jobs <- job{indexedNames[img.Name], img}
}
}()
output = make(chan task, e.Workers)
wg := &sync.WaitGroup{}
for range e.WorkersRatio(50) {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
var img image.Image
var err error
if !e.Dry {
var f io.ReadCloser
f, err = job.F.Open()
if err == nil {
img, _, err = image.Decode(f)
}
_ = f.Close()
}
p, fn := filepath.Split(filepath.Clean(job.F.Name))
if err != nil {
img = e.corruptedImage(p, fn)
}
output <- task{
Id: job.Id,
Image: img,
Path: p,
Name: fn,
Error: err,
}
}
}()
}
go func() {
wg.Wait()
close(output)
_ = r.Close()
}()
return
}
// load a rar file that include images
func (e ePUBImageProcessor) loadCbr() (totalImages int, output chan task, err error) {
var isSolid bool
files, err := rardecode.List(e.Input)
if err != nil {
return
}
names := make([]string, 0)
for _, f := range files {
if !f.IsDir && e.isSupportedImage(f.Name) {
if f.Solid {
isSolid = true
}
names = append(names, f.Name)
}
}
totalImages = len(names)
if totalImages == 0 {
err = errNoImagesFound
return
}
sort.Sort(sortpath.By(names, e.SortPathMode))
indexedNames := make(map[string]int)
for i, name := range names {
indexedNames[name] = i
}
type job struct {
Id int
Name string
Open func() (io.ReadCloser, error)
}
jobs := make(chan job)
go func() {
defer close(jobs)
if isSolid && !e.Dry {
r, rerr := rardecode.OpenReader(e.Input)
if rerr != nil {
utils.Fatalf("\nerror processing image %s: %s\n", e.Input, rerr)
}
defer func(r *rardecode.ReadCloser) {
_ = r.Close()
}(r)
for {
f, rerr := r.Next()
if rerr != nil {
if rerr == io.EOF {
break
}
utils.Fatalf("\nerror processing image %s: %s\n", f.Name, rerr)
}
if i, ok := indexedNames[f.Name]; ok {
var b bytes.Buffer
_, rerr = io.Copy(&b, r)
if rerr != nil {
utils.Fatalf("\nerror processing image %s: %s\n", f.Name, rerr)
}
jobs <- job{i, f.Name, func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(b.Bytes())), nil
}}
}
}
} else {
for _, img := range files {
if i, ok := indexedNames[img.Name]; ok {
jobs <- job{i, img.Name, img.Open}
}
}
}
}()
// send file to the queue
output = make(chan task, e.Workers)
wg := &sync.WaitGroup{}
for range e.WorkersRatio(50) {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
var img image.Image
var err error
if !e.Dry {
var f io.ReadCloser
f, err = job.Open()
if err == nil {
img, _, err = image.Decode(f)
}
_ = f.Close()
}
p, fn := filepath.Split(filepath.Clean(job.Name))
if err != nil {
img = e.corruptedImage(p, fn)
}
output <- task{
Id: job.Id,
Image: img,
Path: p,
Name: fn,
Error: err,
}
}
}()
}
go func() {
wg.Wait()
close(output)
}()
return
}
// extract image from a pdf
func (e ePUBImageProcessor) loadPdf() (totalImages int, output chan task, err error) {
pdf := pdfread.Load(e.Input)
if pdf == nil {
err = fmt.Errorf("can't read pdf")
return
}
totalImages = len(pdf.Pages())
pageFmt := "page " + utils.FormatNumberOfDigits(totalImages)
output = make(chan task)
go func() {
defer close(output)
defer pdf.Close()
for i := range totalImages {
var img image.Image
var err error
if !e.Dry {
img, err = pdfimage.Extract(pdf, i+1)
}
name := fmt.Sprintf(pageFmt, i+1)
if err != nil {
img = e.corruptedImage("", name)
}
output <- task{
Id: i,
Image: img,
Path: "",
Name: name,
Error: err,
}
}
}()
return
}

View File

@ -1,333 +0,0 @@
// Package epubimageprocessor extract and transform image into a compressed jpeg.
package epubimageprocessor
import (
"image"
"image/color"
"image/draw"
"sync"
"github.com/disintegration/gift"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/epubimage"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/epubimagefilters"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/epubprogress"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/epubzip"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/utils"
"github.com/celogeek/go-comic-converter/v3/pkg/epuboptions"
)
type EPUBImageProcessor interface {
Load() (images []epubimage.EPUBImage, err error)
CoverTitleData(o CoverTitleDataOptions) (epubzip.Image, error)
}
type ePUBImageProcessor struct {
epuboptions.EPUBOptions
}
func New(o epuboptions.EPUBOptions) EPUBImageProcessor {
return ePUBImageProcessor{o}
}
// Load extract and convert images
func (e ePUBImageProcessor) Load() (images []epubimage.EPUBImage, err error) {
images = make([]epubimage.EPUBImage, 0)
imageCount, imageInput, err := e.load()
if err != nil {
return nil, err
}
// dry run, skip conversion
if e.Dry {
for img := range imageInput {
images = append(images, epubimage.EPUBImage{
Id: img.Id,
Path: img.Path,
Name: img.Name,
Format: e.Image.Format,
})
}
return images, nil
}
imageOutput := make(chan epubimage.EPUBImage)
// processing
bar := epubprogress.New(epubprogress.Options{
Quiet: e.Quiet,
Json: e.Json,
Max: imageCount,
Description: "Processing",
CurrentJob: 1,
TotalJob: 2,
})
wg := &sync.WaitGroup{}
imgStorage, err := epubzip.NewStorageImageWriter(e.ImgStorage(), e.Image.Format)
if err != nil {
_ = bar.Close()
return nil, err
}
wr := 50
if e.Image.Format == "png" {
wr = 100
}
for range e.WorkersRatio(wr) {
wg.Add(1)
go func() {
defer wg.Done()
for input := range imageInput {
img := e.transformImage(input, 0, e.Image.Manga)
// do not keep double page if requested
if !(img.DoublePage && input.Id > 0 &&
e.EPUBOptions.Image.AutoSplitDoublePage && !e.EPUBOptions.Image.KeepDoublePageIfSplit) {
if err = imgStorage.Add(img.EPUBImgPath(), img.Raw, e.Image.Quality); err != nil {
_ = bar.Close()
utils.Fatalf("error with %s: %s", input.Name, err)
}
// do not keep raw image except for cover
if img.Id > 0 {
img.Raw = nil
}
imageOutput <- img
}
// DOUBLE PAGE
if !e.Image.AutoSplitDoublePage || // No split required
!img.DoublePage || // Not a double page
(e.Image.HasCover && img.Id == 0) { // Cover
continue
}
for i, b := range []bool{e.Image.Manga, !e.Image.Manga} {
img = e.transformImage(input, i+1, b)
if err = imgStorage.Add(img.EPUBImgPath(), img.Raw, e.Image.Quality); err != nil {
_ = bar.Close()
utils.Fatalf("error with %s: %s", input.Name, err)
}
img.Raw = nil
imageOutput <- img
}
}
}()
}
go func() {
wg.Wait()
_ = imgStorage.Close()
close(imageOutput)
}()
for img := range imageOutput {
if img.Part == 0 {
_ = bar.Add(1)
}
if e.Image.NoBlankImage && img.IsBlank {
continue
}
images = append(images, img)
}
_ = bar.Close()
if len(images) == 0 {
return nil, errNoImagesFound
}
return images, nil
}
func (e ePUBImageProcessor) createImage(src image.Image, r image.Rectangle) draw.Image {
if e.EPUBOptions.Image.GrayScale {
return image.NewGray(r)
}
switch t := src.(type) {
case *image.Gray:
return image.NewGray(r)
case *image.Gray16:
return image.NewGray16(r)
case *image.RGBA:
return image.NewRGBA(r)
case *image.RGBA64:
return image.NewRGBA64(r)
case *image.NRGBA:
return image.NewNRGBA(r)
case *image.NRGBA64:
return image.NewNRGBA64(r)
case *image.Alpha:
return image.NewAlpha(r)
case *image.Alpha16:
return image.NewAlpha16(r)
case *image.CMYK:
return image.NewCMYK(r)
case *image.Paletted:
return image.NewPaletted(r, t.Palette)
default:
return image.NewNRGBA64(r)
}
}
// transform image into 1 or 3 images
// only doublepage with autosplit has 3 versions
func (e ePUBImageProcessor) transformImage(input task, part int, right bool) epubimage.EPUBImage {
g := gift.New()
src := input.Image
srcBounds := src.Bounds()
// In portrait only, we don't need to keep aspect ratio between each split.
// We first cut, the crop.
if part > 0 && !e.Image.KeepSplitDoublePageAspect {
g.Add(epubimagefilters.CropSplitDoublePage(right))
}
// Lookup for margin if crop is enable or if we want to remove blank image
if e.Image.Crop.Enabled || e.Image.NoBlankImage {
f := epubimagefilters.AutoCrop(
src,
g.Bounds(src.Bounds()),
e.Image.Crop.Left,
e.Image.Crop.Up,
e.Image.Crop.Right,
e.Image.Crop.Bottom,
e.Image.Crop.Limit,
e.Image.Crop.SkipIfLimitReached,
)
// detect if blank image
size := f.Bounds(srcBounds)
isBlank := size.Dx() == 0 && size.Dy() == 0
// crop is enable or if blank image with noblankimage options
if e.Image.Crop.Enabled || (e.Image.NoBlankImage && isBlank) {
g.Add(f)
}
}
// With landscape support, we need to keep aspect ratio between each split
// We first crop, then cut
if part > 0 && e.Image.KeepSplitDoublePageAspect {
g.Add(epubimagefilters.CropSplitDoublePage(right))
}
dstBounds := g.Bounds(src.Bounds())
// Original && Cropped version need to landscape oriented
// Only part 0 can be a double page
isDoublePage := part == 0 && srcBounds.Dx() > srcBounds.Dy() && dstBounds.Dx() > dstBounds.Dy()
if e.Image.AutoRotate && isDoublePage {
g.Add(gift.Rotate90())
}
if e.Image.AutoContrast {
g.Add(epubimagefilters.AutoContrast())
}
if e.Image.Contrast != 0 {
g.Add(gift.Contrast(float32(e.Image.Contrast)))
}
if e.Image.Brightness != 0 {
g.Add(gift.Brightness(float32(e.Image.Brightness)))
}
if e.Image.Resize {
g.Add(gift.ResizeToFit(e.Image.View.Width, e.Image.View.Height, gift.LanczosResampling))
}
if e.Image.GrayScale {
var f gift.Filter
switch e.Image.GrayScaleMode {
case 1: // average
f = gift.ColorFunc(func(r0, g0, b0, a0 float32) (r float32, g float32, b float32, a float32) {
y := (r0 + g0 + b0) / 3
return y, y, y, a0
})
case 2: // luminance
f = gift.ColorFunc(func(r0, g0, b0, a0 float32) (r float32, g float32, b float32, a float32) {
y := 0.2126*r0 + 0.7152*g0 + 0.0722*b0
return y, y, y, a0
})
default:
f = gift.Grayscale()
}
g.Add(f)
}
g.Add(epubimagefilters.Pixel())
dst := e.createImage(src, g.Bounds(src.Bounds()))
g.Draw(dst, src)
return epubimage.EPUBImage{
Id: input.Id,
Part: part,
Raw: dst,
Width: dst.Bounds().Dx(),
Height: dst.Bounds().Dy(),
IsBlank: dst.Bounds().Dx() == 1 && dst.Bounds().Dy() == 1,
DoublePage: isDoublePage,
Path: input.Path,
Name: input.Name,
Format: e.Image.Format,
OriginalAspectRatio: float64(src.Bounds().Dy()) / float64(src.Bounds().Dx()),
Error: input.Error,
}
}
type CoverTitleDataOptions struct {
Src image.Image
Name string
Text string
Align string
PctWidth int
PctMargin int
MaxFontSize int
BorderSize int
}
func (e ePUBImageProcessor) cover16LevelOfGray(bounds image.Rectangle) draw.Image {
return image.NewPaletted(bounds, color.Palette{
color.Gray{},
color.Gray{Y: 0x11},
color.Gray{Y: 0x22},
color.Gray{Y: 0x33},
color.Gray{Y: 0x44},
color.Gray{Y: 0x55},
color.Gray{Y: 0x66},
color.Gray{Y: 0x77},
color.Gray{Y: 0x88},
color.Gray{Y: 0x99},
color.Gray{Y: 0xAA},
color.Gray{Y: 0xBB},
color.Gray{Y: 0xCC},
color.Gray{Y: 0xDD},
color.Gray{Y: 0xEE},
color.Gray{Y: 0xFF},
})
}
// CoverTitleData create a title page with the cover
func (e ePUBImageProcessor) CoverTitleData(o CoverTitleDataOptions) (epubzip.Image, error) {
// Create a blur version of the cover
g := gift.New(epubimagefilters.CoverTitle(o.Text, o.Align, o.PctWidth, o.PctMargin, o.MaxFontSize, o.BorderSize))
var dst draw.Image
if o.Name == "cover" && e.Image.GrayScale {
dst = e.cover16LevelOfGray(o.Src.Bounds())
} else {
dst = e.createImage(o.Src, g.Bounds(o.Src.Bounds()))
}
g.Draw(dst, o.Src)
return epubzip.CompressImage(
"OEBPS/Images/"+o.Name+".jpeg",
"jpeg",
dst,
e.Image.Quality,
)
}

View File

@ -1,66 +0,0 @@
// Package epubprogress create a progress bar with custom settings.
package epubprogress
import (
"encoding/json"
"fmt"
"os"
"time"
"github.com/schollz/progressbar/v3"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/utils"
)
type Options struct {
Quiet bool
Json bool
Max int
Description string
CurrentJob int
TotalJob int
}
type EPUBProgress interface {
Add(num int) error
Close() error
}
func New(o Options) EPUBProgress {
if o.Quiet {
return progressbar.DefaultSilent(int64(o.Max))
}
if o.Json {
return &jsonprogress{
o: o,
e: json.NewEncoder(os.Stdout),
}
}
fmtJob := utils.FormatNumberOfDigits(o.TotalJob)
return progressbar.NewOptions(o.Max,
progressbar.OptionSetWriter(os.Stderr),
progressbar.OptionThrottle(65*time.Millisecond),
progressbar.OptionOnCompletion(func() {
utils.Println()
}),
progressbar.OptionSetDescription(fmt.Sprintf(
"["+fmtJob+"/"+fmtJob+"] %-15s",
o.CurrentJob,
o.TotalJob,
o.Description,
)),
progressbar.OptionSetWidth(60),
progressbar.OptionShowCount(),
progressbar.OptionSetRenderBlankState(true),
progressbar.OptionEnableColorCodes(true),
progressbar.OptionSetTheme(progressbar.Theme{
Saucer: "[green]=[reset]",
SaucerHead: "[green]>[reset]",
SaucerPadding: " ",
BarStart: "[",
BarEnd: "]",
}),
)
}

View File

@ -1,33 +0,0 @@
package epubprogress
import (
"encoding/json"
)
type jsonprogress struct {
o Options
e *json.Encoder
current int
}
func (p *jsonprogress) Add(num int) error {
p.current += num
return p.e.Encode(map[string]any{
"type": "epubprogress",
"data": map[string]any{
"epubprogress": map[string]any{
"current": p.current,
"total": p.o.Max,
},
"steps": map[string]any{
"current": p.o.CurrentJob,
"total": p.o.TotalJob,
},
"description": p.o.Description,
},
})
}
func (p *jsonprogress) Close() error {
return nil
}

View File

@ -1,6 +0,0 @@
package epubtemplates
import _ "embed"
//go:embed "applebooks.xml.tmpl"
var AppleBooks string

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<display_options>
<platform name="*">
<option name="interactive">true</option>
<option name="fixed-layout">true</option>
<option name="open-to-spread">true</option>
</platform>
</display_options>

View File

@ -1,7 +0,0 @@
// Package epubtemplates Templates use to create xml files of the EPUB.
package epubtemplates
import _ "embed"
//go:embed "blank.xhtml.tmpl"
var Blank string

View File

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head>
<meta charset="utf-8" />
<title>{{ .Title }}</title>
<link href="style.css" type="text/css" rel="stylesheet"/>
<meta name="viewport" content="{{ .ViewPort }}"/>
</head>
<body>
</body>
</html>

View File

@ -1,6 +0,0 @@
package epubtemplates
import _ "embed"
//go:embed "container.xml.tmpl"
var Container string

View File

@ -1,7 +0,0 @@
<?xml version="1.0"?>
<container version="1.0"
xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>

View File

@ -1,282 +0,0 @@
package epubtemplates
import (
"github.com/beevik/etree"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/epubimage"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/utils"
"github.com/celogeek/go-comic-converter/v3/pkg/epuboptions"
)
type Content struct {
Title string
HasTitlePage bool
UID string
Author string
Publisher string
UpdatedAt string
ImageOptions epuboptions.Image
Cover epubimage.EPUBImage
Images []epubimage.EPUBImage
Current int
Total int
}
type tagAttrs map[string]string
type tag struct {
name string
attrs tagAttrs
value string
}
// Get create the content file
//
//goland:noinspection HttpUrlsUsage,HttpUrlsUsage,HttpUrlsUsage,HttpUrlsUsage
func (o Content) String() string {
doc := etree.NewDocument()
doc.CreateProcInst("xml", `version="1.0" encoding="UTF-8"`)
pkg := doc.CreateElement("package")
pkg.CreateAttr("xmlns", "http://www.idpf.org/2007/opf")
pkg.CreateAttr("unique-identifier", "ean")
pkg.CreateAttr("version", "3.0")
pkg.CreateAttr("prefix", "rendition: http://www.idpf.org/vocab/rendition/#")
addToElement := func(elm *etree.Element, meth func() []tag) {
for _, p := range meth() {
meta := elm.CreateElement(p.name)
for k, v := range p.attrs {
meta.CreateAttr(k, v)
}
meta.SortAttrs()
if p.value != "" {
meta.CreateText(p.value)
}
}
}
metadata := pkg.CreateElement("metadata")
metadata.CreateAttr("xmlns:dc", "http://purl.org/dc/elements/1.1/")
metadata.CreateAttr("xmlns:opf", "http://www.idpf.org/2007/opf")
addToElement(metadata, o.getMeta)
manifest := pkg.CreateElement("manifest")
addToElement(manifest, o.getManifest)
spine := pkg.CreateElement("spine")
if o.ImageOptions.Manga {
spine.CreateAttr("page-progression-direction", "rtl")
} else {
spine.CreateAttr("page-progression-direction", "ltr")
}
if o.ImageOptions.View.PortraitOnly {
addToElement(spine, o.getSpinePortrait)
} else {
addToElement(spine, o.getSpineAuto)
}
guide := pkg.CreateElement("guide")
addToElement(guide, o.getGuide)
doc.Indent(2)
r, _ := doc.WriteToString()
return r
}
// metadata part of the content
func (o Content) getMeta() []tag {
metas := []tag{
{"meta", tagAttrs{"property": "dcterms:modified"}, o.UpdatedAt},
{"meta", tagAttrs{"property": "schema:accessMode"}, "visual"},
{"meta", tagAttrs{"property": "schema:accessModeSufficient"}, "visual"},
{"meta", tagAttrs{"property": "schema:accessibilityHazard"}, "noFlashingHazard"},
{"meta", tagAttrs{"property": "schema:accessibilityHazard"}, "noMotionSimulationHazard"},
{"meta", tagAttrs{"property": "schema:accessibilityHazard"}, "noSoundHazard"},
{"meta", tagAttrs{"name": "book-type", "content": "comic"}, ""},
{"opf:meta", tagAttrs{"name": "fixed-layout", "content": "true"}, ""},
{"opf:meta", tagAttrs{"name": "original-resolution", "content": o.ImageOptions.View.Dimension()}, ""},
{"dc:title", tagAttrs{}, o.Title},
{"dc:identifier", tagAttrs{"id": "ean"}, "urn:uuid:" + o.UID},
{"dc:language", tagAttrs{}, "en"},
{"dc:creator", tagAttrs{}, o.Author},
{"dc:publisher", tagAttrs{}, o.Publisher},
{"dc:contributor", tagAttrs{}, "Go Comic Convertor"},
{"dc:date", tagAttrs{}, o.UpdatedAt},
}
if o.ImageOptions.View.PortraitOnly {
metas = append(metas, []tag{
{"meta", tagAttrs{"property": "rendition:layout"}, "pre-paginated"},
{"meta", tagAttrs{"property": "rendition:spread"}, "none"},
{"meta", tagAttrs{"property": "rendition:orientation"}, "portrait"},
}...)
} else {
metas = append(metas, []tag{
{"meta", tagAttrs{"property": "rendition:layout"}, "pre-paginated"},
{"meta", tagAttrs{"property": "rendition:spread"}, "auto"},
{"meta", tagAttrs{"property": "rendition:orientation"}, "auto"},
}...)
}
if o.ImageOptions.Manga {
metas = append(metas, tag{"meta", tagAttrs{"name": "primary-writing-mode", "content": "horizontal-rl"}, ""})
} else {
metas = append(metas, tag{"meta", tagAttrs{"name": "primary-writing-mode", "content": "horizontal-lr"}, ""})
}
metas = append(metas, tag{"meta", tagAttrs{"name": "cover", "content": "img_cover"}, ""})
if o.Total > 1 {
metas = append(
metas,
tag{"meta", tagAttrs{"name": "calibre:series", "content": o.Title}, ""},
tag{"meta", tagAttrs{"name": "calibre:series_index", "content": utils.IntToString(o.Current)}, ""},
)
}
return metas
}
func (o Content) getManifest() []tag {
var imageTags, pageTags, spaceTags []tag
addTag := func(img epubimage.EPUBImage, withSpace bool) {
imageTags = append(imageTags,
tag{"item", tagAttrs{"id": img.ImgKey(), "href": img.ImgPath(), "media-type": img.MediaType()}, ""},
)
pageTags = append(pageTags,
tag{"item", tagAttrs{"id": img.PageKey(), "href": img.PagePath(), "media-type": "application/xhtml+xml"}, ""},
)
if withSpace {
spaceTags = append(spaceTags,
tag{"item", tagAttrs{"id": img.SpaceKey(), "href": img.SpacePath(), "media-type": "application/xhtml+xml"}, ""},
)
}
}
items := []tag{
{"item", tagAttrs{"id": "toc", "href": "toc.xhtml", "properties": "nav", "media-type": "application/xhtml+xml"}, ""},
{"item", tagAttrs{"id": "css", "href": "Text/style.css", "media-type": "text/css"}, ""},
{"item", tagAttrs{"id": "page_cover", "href": "Text/cover.xhtml", "media-type": "application/xhtml+xml"}, ""},
{"item", tagAttrs{"id": "img_cover", "href": "Images/cover.jpeg", "media-type": "image/jpeg"}, ""},
}
if o.HasTitlePage {
items = append(items,
tag{"item", tagAttrs{"id": "page_title", "href": "Text/title.xhtml", "media-type": "application/xhtml+xml"}, ""},
tag{"item", tagAttrs{"id": "img_title", "href": "Images/title.jpeg", "media-type": "image/jpeg"}, ""},
)
if !o.ImageOptions.View.PortraitOnly {
items = append(items, tag{"item", tagAttrs{"id": "space_title", "href": "Text/space_title.xhtml", "media-type": "application/xhtml+xml"}, ""})
}
}
lastImage := o.Images[len(o.Images)-1]
for _, img := range o.Images {
addTag(
img,
!o.ImageOptions.View.PortraitOnly &&
(img.DoublePage ||
(!o.ImageOptions.KeepDoublePageIfSplit && img.Part == 1) ||
(img.Part == 0 && img == lastImage)))
}
items = append(items, imageTags...)
items = append(items, pageTags...)
items = append(items, spaceTags...)
return items
}
// spine part of the content
func (o Content) getSpineAuto() []tag {
isOnTheRight := !o.ImageOptions.Manga
if o.ImageOptions.AppleBookCompatibility {
isOnTheRight = !isOnTheRight
}
getSpread := func(isDoublePage bool) string {
isOnTheRight = !isOnTheRight
if isDoublePage {
// Center the double page then start back to comic mode (mange/normal)
isOnTheRight = !o.ImageOptions.Manga
return "rendition:page-spread-center"
}
if isOnTheRight {
return "rendition:page-spread-right"
} else {
return "rendition:page-spread-left"
}
}
getSpreadBlank := func() string {
return getSpread(false) + " layout-blank"
}
var spine []tag
if o.HasTitlePage {
if !o.ImageOptions.AppleBookCompatibility {
spine = append(spine,
tag{"itemref", tagAttrs{"idref": "space_title", "properties": getSpreadBlank()}, ""},
tag{"itemref", tagAttrs{"idref": "page_title", "properties": getSpread(false)}, ""},
)
} else {
spine = append(spine,
tag{"itemref", tagAttrs{"idref": "page_title", "properties": getSpread(true)}, ""},
)
}
}
for i, img := range o.Images {
if (img.DoublePage || img.Part == 1) && o.ImageOptions.Manga == isOnTheRight {
spine = append(spine, tag{
"itemref",
tagAttrs{"idref": img.SpaceKey(), "properties": getSpreadBlank()},
"",
})
}
// register position for style adjustment
img.Position = getSpread(img.DoublePage)
spine = append(spine, tag{
"itemref",
tagAttrs{"idref": img.PageKey(), "properties": img.Position},
"",
})
// save position, img is a value type
o.Images[i] = img
}
if o.ImageOptions.Manga == isOnTheRight {
spine = append(spine, tag{
"itemref",
tagAttrs{"idref": o.Images[len(o.Images)-1].SpaceKey(), "properties": getSpread(false)},
"",
})
}
return spine
}
func (o Content) getSpinePortrait() []tag {
var spine []tag
if o.HasTitlePage {
spine = append(spine,
tag{"itemref", tagAttrs{"idref": "page_title"}, ""},
)
}
for _, img := range o.Images {
spine = append(spine, tag{
"itemref",
tagAttrs{"idref": img.PageKey()},
"",
})
}
return spine
}
// getGuide Section guide of the content
func (o Content) getGuide() []tag {
return []tag{
{"reference", tagAttrs{"type": "cover", "title": "cover", "href": "Text/cover.xhtml"}, ""},
{"reference", tagAttrs{"type": "text", "title": "content", "href": o.Images[0].PagePath()}, ""},
}
}

View File

@ -1,19 +0,0 @@
body {
color: #{{ .View.Color.Foreground }};
background: #{{ .View.Color.Background }};
top: 0;
left: 0;
margin: 0;
padding: 0;
width: {{ .View.Width }}px;
height: {{ .View.Height }}px;
text-align: center;
}
img {
position: absolute;
margin:0;
padding:0;
z-index:0;
object-fit: contain;
}

View File

@ -1,6 +0,0 @@
package epubtemplates
import _ "embed"
//go:embed "style.css.tmpl"
var Style string

View File

@ -1,6 +0,0 @@
package epubtemplates
import _ "embed"
//go:embed "text.xhtml.tmpl"
var Text string

View File

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head>
<meta charset="utf-8" />
<title>{{ .Title }}</title>
<link href="style.css" type="text/css" rel="stylesheet"/>
<meta name="viewport" content="{{ .ViewPort }}"/>
</head>
<body>
<img src="../{{ .ImagePath }}" alt="{{ .Title }}" style="{{ .ImageStyle }}"/>
</body>
</html>

View File

@ -1,74 +0,0 @@
package epubtemplates
import (
"path/filepath"
"strings"
"github.com/beevik/etree"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/epubimage"
)
// Toc create toc
//
//goland:noinspection HttpUrlsUsage
func Toc(title string, hasTitle bool, stripFirstDirectoryFromToc bool, images []epubimage.EPUBImage) string {
doc := etree.NewDocument()
doc.CreateProcInst("xml", `version="1.0" encoding="UTF-8"`)
doc.CreateDirective("DOCTYPE html")
html := doc.CreateElement("html")
html.CreateAttr("xmlns", "http://www.w3.org/1999/xhtml")
html.CreateAttr("xmlns:epub", "http://www.idpf.org/2007/ops")
html.CreateElement("head").CreateElement("title").CreateText(title)
body := html.CreateElement("body")
nav := body.CreateElement("nav")
nav.CreateAttr("epub:type", "toc")
nav.CreateAttr("id", "toc")
nav.CreateElement("h2").CreateText(title)
ol := etree.NewElement("ol")
paths := map[string]*etree.Element{".": ol}
for _, img := range images {
currentPath := "."
for _, path := range strings.Split(img.Path, string(filepath.Separator)) {
parentPath := currentPath
currentPath = filepath.Join(currentPath, path)
if _, ok := paths[currentPath]; ok {
continue
}
t := paths[parentPath].CreateElement("li")
link := t.CreateElement("a")
link.CreateAttr("href", img.PagePath())
link.CreateText(path)
paths[currentPath] = t.CreateElement("ol")
}
}
if len(ol.ChildElements()) == 1 && stripFirstDirectoryFromToc {
ol = ol.FindElement("/li/ol")
}
for _, v := range ol.FindElements("//ol") {
if len(v.ChildElements()) == 0 {
v.Parent().RemoveChild(v)
}
}
beginning := etree.NewElement("li")
beginningLink := beginning.CreateElement("a")
if hasTitle {
beginningLink.CreateAttr("href", "Text/title.xhtml")
} else {
beginningLink.CreateAttr("href", images[0].PagePath())
}
beginningLink.CreateText(title)
ol.InsertChildAt(0, beginning)
nav.AddChild(ol)
doc.Indent(2)
r, _ := doc.WriteToString()
return r
}

View File

@ -1,83 +0,0 @@
/*
Package epubtree Organize a list of filename with their path into a tree of directories.
Example:
- A/B/C/D.jpg
- A/B/C/E.jpg
- A/B/F/G.jpg
This is transformed like:
A
B
C
D.jpg
E.jpg
F
G.jpg
*/
package epubtree
import (
"path/filepath"
"strings"
)
type EPUBTree struct {
nodes map[string]*Node
}
type Node struct {
value string
children []*Node
}
// New initialize tree with a root node
func New() *EPUBTree {
return &EPUBTree{map[string]*Node{
".": {".", []*Node{}},
}}
}
// Root root node
func (n *EPUBTree) Root() *Node {
return n.nodes["."]
}
// Add the filename to the tree
func (n *EPUBTree) Add(filename string) {
cn := n.Root()
cp := ""
for _, p := range strings.Split(filepath.Clean(filename), string(filepath.Separator)) {
cp = filepath.Join(cp, p)
if _, ok := n.nodes[cp]; !ok {
n.nodes[cp] = &Node{value: p, children: []*Node{}}
cn.children = append(cn.children, n.nodes[cp])
}
cn = n.nodes[cp]
}
}
func (n *Node) ChildCount() int {
return len(n.children)
}
func (n *Node) FirstChild() *Node {
return n.children[0]
}
// WriteString string version of the tree
func (n *Node) WriteString(indent string) string {
r := strings.Builder{}
if indent != "" {
r.WriteString(indent)
r.WriteString("- ")
r.WriteString(n.value)
r.WriteString("\n")
}
indent += " "
for _, c := range n.children {
r.WriteString(c.WriteString(indent))
}
return r.String()
}

View File

@ -1,91 +0,0 @@
/*
Package epubzip Helper to write EPUB files.
We create a zip with the magic EPUB mimetype.
*/
package epubzip
import (
"archive/zip"
"os"
"time"
)
type EPUBZip struct {
w *os.File
wz *zip.Writer
}
// New create a new EPUB
func New(path string) (EPUBZip, error) {
w, err := os.Create(path)
if err != nil {
return EPUBZip{}, err
}
wz := zip.NewWriter(w)
return EPUBZip{w, wz}, nil
}
// Close compress pipe and file.
func (e EPUBZip) Close() error {
if err := e.wz.Close(); err != nil {
return err
}
return e.w.Close()
}
// WriteMagic Write mimetype, in a very specific way.
//
// This will be valid with epubcheck tools.
func (e EPUBZip) WriteMagic() error {
t := time.Now().UTC()
//goland:noinspection GoDeprecation
fh := zip.FileHeader{
Name: "mimetype",
Method: zip.Store,
Modified: t,
ModifiedTime: uint16(t.Second()/2 + t.Minute()<<5 + t.Hour()<<11),
ModifiedDate: uint16(t.Day() + int(t.Month())<<5 + (t.Year()-1980)<<9),
CompressedSize64: 20,
UncompressedSize64: 20,
CRC32: 0x2cab616f,
}
fh.CreatorVersion = fh.CreatorVersion&0xff00 | 20 // preserve compatibility byte
fh.ReaderVersion = 20
fh.SetMode(0600)
m, err := e.wz.CreateRaw(&fh)
if err != nil {
return err
}
_, err = m.Write([]byte("application/epub+zip"))
return err
}
func (e EPUBZip) Copy(fz *zip.File) error {
return e.wz.Copy(fz)
}
// WriteRaw Write image. They are already compressed, so we write them down directly.
func (e EPUBZip) WriteRaw(raw Image) error {
m, err := e.wz.CreateRaw(raw.Header)
if err != nil {
return err
}
_, err = m.Write(raw.Data)
return err
}
// WriteContent Write file. Compressed it using deflate.
func (e EPUBZip) WriteContent(file string, content []byte) error {
m, err := e.wz.CreateHeader(&zip.FileHeader{
Name: file,
Modified: time.Now(),
Method: zip.Deflate,
})
if err != nil {
return err
}
_, err = m.Write(content)
return err
}

View File

@ -1,104 +0,0 @@
package epubzip
import (
"archive/zip"
"bytes"
"compress/flate"
"fmt"
"hash/crc32"
"image"
"image/jpeg"
"image/png"
"time"
)
type Image struct {
Header *zip.FileHeader
Data []byte
}
// CompressImage create gzip encoded jpeg
func CompressImage(filename string, format string, img image.Image, quality int) (Image, error) {
var (
data, cdata bytes.Buffer
err error
)
switch format {
case "png":
err = png.Encode(&data, img)
case "jpeg":
err = jpeg.Encode(&data, img, &jpeg.Options{Quality: quality})
default:
err = fmt.Errorf("unknown format %q", format)
}
if err != nil {
return Image{}, err
}
wcdata, err := flate.NewWriter(&cdata, flate.BestCompression)
if err != nil {
return Image{}, err
}
_, err = wcdata.Write(data.Bytes())
if err != nil {
return Image{}, err
}
err = wcdata.Close()
if err != nil {
return Image{}, err
}
t := time.Now()
//goland:noinspection GoDeprecation
return Image{
&zip.FileHeader{
Name: filename,
CompressedSize64: uint64(cdata.Len()),
UncompressedSize64: uint64(data.Len()),
CRC32: crc32.Checksum(data.Bytes(), crc32.IEEETable),
Method: zip.Deflate,
ModifiedTime: uint16(t.Second()/2 + t.Minute()<<5 + t.Hour()<<11),
ModifiedDate: uint16(t.Day() + int(t.Month())<<5 + (t.Year()-1980)<<9),
},
cdata.Bytes(),
}, nil
}
func CompressRaw(filename string, uncompressedData []byte) (Image, error) {
var (
cdata bytes.Buffer
err error
)
wcdata, err := flate.NewWriter(&cdata, flate.BestCompression)
if err != nil {
return Image{}, err
}
_, err = wcdata.Write(uncompressedData)
if err != nil {
return Image{}, err
}
err = wcdata.Close()
if err != nil {
return Image{}, err
}
t := time.Now()
//goland:noinspection GoDeprecation
return Image{
&zip.FileHeader{
Name: filename,
CompressedSize64: uint64(cdata.Len()),
UncompressedSize64: uint64(len(uncompressedData)),
CRC32: crc32.Checksum(uncompressedData, crc32.IEEETable),
Method: zip.Deflate,
ModifiedTime: uint16(t.Second()/2 + t.Minute()<<5 + t.Hour()<<11),
ModifiedDate: uint16(t.Day() + int(t.Month())<<5 + (t.Year()-1980)<<9),
},
cdata.Bytes(),
}, nil
}

View File

@ -1,53 +0,0 @@
package epubzip
import (
"archive/zip"
"os"
)
type StorageImageReader struct {
filename string
fh *os.File
fz *zip.Reader
files map[string]*zip.File
}
func NewStorageImageReader(filename string) (StorageImageReader, error) {
fh, err := os.Open(filename)
if err != nil {
return StorageImageReader{}, err
}
s, err := fh.Stat()
if err != nil {
return StorageImageReader{}, err
}
fz, err := zip.NewReader(fh, s.Size())
if err != nil {
return StorageImageReader{}, err
}
files := map[string]*zip.File{}
for _, z := range fz.File {
files[z.Name] = z
}
return StorageImageReader{filename, fh, fz, files}, nil
}
func (e StorageImageReader) Get(filename string) *zip.File {
return e.files[filename]
}
func (e StorageImageReader) Size(filename string) uint64 {
if img, ok := e.files[filename]; ok {
return img.CompressedSize64 + 30 + uint64(len(img.Name))
}
return 0
}
func (e StorageImageReader) Close() error {
return e.fh.Close()
}
func (e StorageImageReader) Remove() error {
return os.Remove(e.filename)
}

View File

@ -1,73 +0,0 @@
package epubzip
import (
"archive/zip"
"image"
"os"
"sync"
)
type StorageImageWriter struct {
fh *os.File
fz *zip.Writer
format string
mut *sync.Mutex
}
func NewStorageImageWriter(filename string, format string) (StorageImageWriter, error) {
fh, err := os.Create(filename)
if err != nil {
return StorageImageWriter{}, err
}
fz := zip.NewWriter(fh)
return StorageImageWriter{fh, fz, format, &sync.Mutex{}}, nil
}
func (e StorageImageWriter) Close() error {
if err := e.fz.Close(); err != nil {
_ = e.fh.Close()
return err
}
return e.fh.Close()
}
func (e StorageImageWriter) Add(filename string, img image.Image, quality int) error {
zipImage, err := CompressImage(filename, e.format, img, quality)
if err != nil {
return err
}
e.mut.Lock()
defer e.mut.Unlock()
fh, err := e.fz.CreateRaw(zipImage.Header)
if err != nil {
return err
}
_, err = fh.Write(zipImage.Data)
if err != nil {
return err
}
return nil
}
func (e StorageImageWriter) AddRaw(filename string, uncompressedData []byte) error {
zipImage, err := CompressRaw(filename, uncompressedData)
if err != nil {
return err
}
e.mut.Lock()
defer e.mut.Unlock()
fh, err := e.fz.CreateRaw(zipImage.Header)
if err != nil {
return err
}
_, err = fh.Write(zipImage.Data)
if err != nil {
return err
}
return nil
}

View File

@ -1,51 +0,0 @@
/*
Package sortpath support sorting of path that may include number.
A series of path can look like:
- Tome1/Chap1/Image1.jpg
- Tome1/Chap2/Image1.jpg
- Tome1/Chap10/Image2.jpg
The module will split the string by path,
and compare them by decomposing the string and number part.
The module support 3 mode:
- mode=0 alpha for path and file
- mode=1 alphanumeric for path and alpha for file
- mode=2 alphanumeric for path and file
Example:
files := []string{
'T1/C1/Img1.jpg',
'T1/C2/Img1.jpg',
'T1/C10/Img1.jpg',
}
sort.Sort(sortpath.By(files, 1))
*/
package sortpath
import "sort"
// struct that implement interface for sort.Sort
type by struct {
filenames []string
paths [][]part
}
func (b by) Len() int { return len(b.filenames) }
func (b by) Less(i, j int) bool { return compareParts(b.paths[i], b.paths[j]) < 0 }
func (b by) Swap(i, j int) {
b.filenames[i], b.filenames[j] = b.filenames[j], b.filenames[i]
b.paths[i], b.paths[j] = b.paths[j], b.paths[i]
}
// By use sortpath.By with sort.Sort
func By(filenames []string, mode int) sort.Interface {
var p [][]part
for _, filename := range filenames {
p = append(p, parse(filename, mode))
}
return by{filenames, p}
}

View File

@ -1,83 +0,0 @@
package sortpath
import (
"path/filepath"
"regexp"
"strconv"
"strings"
)
// Strings follow with numbers like: s1, s1.2, s2-3, ...
var splitPathRegex = regexp.MustCompile(`^(.*?)(\d+(?:\.\d+)?)(?:-(\d+(?:\.\d+)?))?$`)
type part struct {
fullname string
name string
number float64
}
// compare part, first check if both include a number,
// if so, compare string part then number part, else compare there as string.
func (a part) compare(b part) float64 {
if a.number == 0 || b.number == 0 {
return float64(strings.Compare(a.fullname, b.fullname))
}
if a.name == b.name {
return a.number - b.number
} else {
return float64(strings.Compare(a.name, b.name))
}
}
// separate from the string the number part.
func parsePart(p string) part {
r := splitPathRegex.FindStringSubmatch(p)
if len(r) == 0 {
return part{p, p, 0}
}
n, err := strconv.ParseFloat(r[2], 64)
if err != nil {
return part{p, p, 0}
}
return part{p, r[1], n}
}
// mode=0 alpha for path and file
// mode=1 alphanumeric for path and alpha for file
// mode=2 alphanumeric for path and file
func parse(filename string, mode int) []part {
pathname, name := filepath.Split(strings.ToLower(filename))
pathname = strings.TrimSuffix(pathname, string(filepath.Separator))
ext := filepath.Ext(name)
name = name[0 : len(name)-len(ext)]
var f []part
for _, p := range strings.Split(pathname, string(filepath.Separator)) {
if mode > 0 { // alphanumeric for path
f = append(f, parsePart(p))
} else {
f = append(f, part{p, p, 0})
}
}
if mode == 2 { // alphanumeric for file
f = append(f, parsePart(name))
} else {
f = append(f, part{name, name, 0})
}
return f
}
// compare 2 full path split into parts
func compareParts(a, b []part) float64 {
m := len(a)
if m > len(b) {
m = len(b)
}
for i := range m {
c := a[i].compare(b[i])
if c != 0 {
return c
}
}
return float64(len(a) - len(b))
}

View File

@ -1,56 +0,0 @@
package utils
import (
"fmt"
"os"
"strconv"
)
func Printf(format string, a ...interface{}) {
_, _ = fmt.Fprintf(os.Stderr, format, a...)
}
func Fatalf(format string, args ...interface{}) {
Printf(format, args...)
os.Exit(1)
}
func Println(a ...interface{}) {
_, _ = fmt.Fprintln(os.Stderr, a...)
}
func Fatalln(a ...interface{}) {
Println(a...)
os.Exit(1)
}
func IntToString(i int) string {
return strconv.FormatInt(int64(i), 10)
}
func FloatToString(f float64, precision int) string {
return strconv.FormatFloat(f, 'f', precision, 64)
}
func BoolToString(b bool) string {
if b {
return "true"
}
return "false"
}
func NumberOfDigits(i int) int {
x, count := 10, 1
if i < 0 {
i = -i
count++
}
for ; x <= i; count++ {
x *= 10
}
return count
}
func FormatNumberOfDigits(i int) string {
return "%0" + IntToString(NumberOfDigits(i)) + "d"
}

View File

@ -1,62 +0,0 @@
package utils
import "fmt"
func ExampleFloatToString() {
fmt.Println("test=" + FloatToString(3.14151617, 0) + "=")
fmt.Println("test=" + FloatToString(3.14151617, 1) + "=")
fmt.Println("test=" + FloatToString(3.14151617, 2) + "=")
fmt.Println("test=" + FloatToString(3.14151617, 4) + "=")
// Output: test=3=
// test=3.1=
// test=3.14=
// test=3.1415=
}
func ExampleIntToString() {
fmt.Println("test=" + IntToString(159) + "=")
// Output: test=159=
}
func ExampleNumberOfDigits() {
fmt.Println(NumberOfDigits(0))
fmt.Println(NumberOfDigits(4))
fmt.Println(NumberOfDigits(10))
fmt.Println(NumberOfDigits(14))
fmt.Println(NumberOfDigits(256))
fmt.Println(NumberOfDigits(2004))
fmt.Println(NumberOfDigits(-5))
fmt.Println(NumberOfDigits(-10))
fmt.Println(NumberOfDigits(-12))
// Output: 1
// 1
// 2
// 2
// 3
// 4
// 2
// 3
// 3
}
func ExampleFormatNumberOfDigits() {
fmt.Println(FormatNumberOfDigits(0))
fmt.Println(FormatNumberOfDigits(4))
fmt.Println(FormatNumberOfDigits(10))
fmt.Println(FormatNumberOfDigits(14))
fmt.Println(FormatNumberOfDigits(256))
fmt.Println(FormatNumberOfDigits(2004))
fmt.Println(FormatNumberOfDigits(-5))
fmt.Println(FormatNumberOfDigits(-10))
fmt.Println(FormatNumberOfDigits(-12))
// Output: %01d
// %01d
// %02d
// %02d
// %03d
// %04d
// %02d
// %03d
// %03d
}

310
main.go
View File

@ -1,134 +1,232 @@
/*
Convert CBZ/CBR/Dir into EPUB for e-reader devices (Kindle Devices, ...)
My goal is to make a simple, cross-platform, and fast tool to convert comics into EPUB.
EPUB is now support by Amazon through [SendToKindle](https://www.amazon.com/gp/sendtokindle/), by Email or by using the App. So I've made it simple to support the size limit constraint of those services.
*/
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"runtime/debug"
"path/filepath"
"strings"
"github.com/tcnksm/go-latest"
imageconverter "github.com/celogeek/go-comic-converter/internal/image-converter"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/converter"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/utils"
"github.com/celogeek/go-comic-converter/v3/pkg/epub"
"github.com/celogeek/go-comic-converter/internal/epub"
)
func main() {
cmd := converter.New()
if err := cmd.LoadConfig(); err != nil {
cmd.Fatal(err)
}
cmd.InitParse()
cmd.Parse()
switch {
case cmd.Options.Version:
version()
case cmd.Options.Save:
save(cmd)
case cmd.Options.Show:
show(cmd)
case cmd.Options.Reset:
reset(cmd)
default:
generate(cmd)
}
type Profile struct {
Code string
Description string
Width int
Height int
}
func version() {
bi, ok := debug.ReadBuildInfo()
if !ok {
utils.Fatalln("failed to fetch current version")
var Profiles = []Profile{
// Kindle
{"K1", "Kindle 1", 600, 670},
{"K11", "Kindle 11", 1072, 1448},
{"K2", "Kindle 2", 600, 670},
{"K34", "Kindle Keyboard/Touch", 600, 800},
{"K578", "Kindle", 600, 800},
{"KDX", "Kindle DX/DXG", 824, 1000},
{"KPW", "Kindle Paperwhite 1/2", 758, 1024},
{"KV", "Kindle Paperwhite 3/4/Voyage/Oasis", 1072, 1448},
{"KPW5", "Kindle Paperwhite 5/Signature Edition", 1236, 1648},
{"KO", "Kindle Oasis 2/3", 1264, 1680},
{"KS", "Kindle Scribe", 1860, 2480},
// Kobo
{"KoMT", "Kobo Mini/Touch", 600, 800},
{"KoG", "Kobo Glo", 768, 1024},
{"KoGHD", "Kobo Glo HD", 1072, 1448},
{"KoA", "Kobo Aura", 758, 1024},
{"KoAHD", "Kobo Aura HD", 1080, 1440},
{"KoAH2O", "Kobo Aura H2O", 1080, 1430},
{"KoAO", "Kobo Aura ONE", 1404, 1872},
{"KoN", "Kobo Nia", 758, 1024},
{"KoC", "Kobo Clara HD/Kobo Clara 2E", 1072, 1448},
{"KoL", "Kobo Libra H2O/Kobo Libra 2", 1264, 1680},
{"KoF", "Kobo Forma", 1440, 1920},
{"KoS", "Kobo Sage", 1440, 1920},
{"KoE", "Kobo Elipsa", 1404, 1872},
}
var ProfilesIdx = map[string]int{}
func init() {
for i, p := range Profiles {
ProfilesIdx[p.Code] = i
}
}
type Option struct {
Input string
Output string
Profile string
Author string
Title string
Quality int
NoCrop bool
Algo string
LimitMb int
}
func (o *Option) String() string {
var desc string
var width, height int
if i, ok := ProfilesIdx[o.Profile]; ok {
profile := Profiles[i]
desc = profile.Description
width = profile.Width
height = profile.Height
}
limitmb := "nolimit"
if o.LimitMb > 0 {
limitmb = fmt.Sprintf("%d Mb", o.LimitMb)
}
githubTag := &latest.GithubTag{
Owner: "celogeek",
Repository: "go-comic-converter",
}
v, err := githubTag.Fetch()
if err != nil {
utils.Fatalln("failed to fetch the latest version")
}
if len(v.Versions) < 1 {
utils.Fatalln("no versions found")
}
latestVersion := v.Versions[0]
return fmt.Sprintf(`Go Comic Converter
utils.Printf(`go-comic-converter
Path : %s
Sum : %s
Version : %s
Available Version: %s
To install the latest version:
$ go install github.com/celogeek/go-comic-converter/v%d@%s
Options:
Input : %s
Output : %s
Profile : %s - %s - %dx%d
Author : %s
Title : %s
Quality : %d
Crop : %v
AlgoGray: %s
LimitMb : %s
`,
bi.Main.Path,
bi.Main.Sum,
bi.Main.Version,
latestVersion.Original(),
latestVersion.Segments()[0],
latestVersion.Original(),
o.Input,
o.Output,
o.Profile,
desc,
width,
height,
o.Author,
o.Title,
o.Quality,
!o.NoCrop,
o.Algo,
limitmb,
)
}
func save(cmd *converter.Converter) {
if err := cmd.Options.SaveConfig(); err != nil {
cmd.Fatal(err)
func main() {
availableProfiles := make([]string, 0)
for _, p := range Profiles {
availableProfiles = append(availableProfiles, fmt.Sprintf(
" - %-7s ( %9s ) - %s",
p.Code,
fmt.Sprintf("%dx%d", p.Width, p.Height),
p.Description,
))
}
utils.Printf(
"%s%s\n\nSaving to %s\n",
cmd.Options.Header(),
cmd.Options.ShowConfig(),
cmd.Options.FileName(),
)
}
func show(cmd *converter.Converter) {
utils.Println(cmd.Options.Header(), cmd.Options.ShowConfig())
}
func reset(cmd *converter.Converter) {
if err := cmd.Options.ResetConfig(); err != nil {
cmd.Fatal(err)
}
utils.Printf(
"%s%s\n\nReset default to %s\n",
cmd.Options.Header(),
cmd.Options.ShowConfig(),
cmd.Options.FileName(),
)
}
func generate(cmd *converter.Converter) {
if err := cmd.Validate(); err != nil {
cmd.Fatal(err)
availableAlgo := make([]string, 0)
for a := range imageconverter.AlgoGray {
availableAlgo = append(availableAlgo, a)
}
if profile := cmd.Options.GetProfile(); profile != nil {
cmd.Options.Image.View.Width = profile.Width
cmd.Options.Image.View.Height = profile.Height
opt := &Option{}
flag.StringVar(&opt.Input, "input", "", "Source of comic to convert: directory, cbz, zip, cbr, rar, pdf")
flag.StringVar(&opt.Output, "output", "", "Output of the epub (directory or epub): (default [INPUT].epub)")
flag.StringVar(&opt.Profile, "profile", "", fmt.Sprintf("Profile to use: \n%s", strings.Join(availableProfiles, "\n")))
flag.StringVar(&opt.Author, "author", "GO Comic Converter", "Author of the epub")
flag.StringVar(&opt.Title, "title", "", "Title of the epub")
flag.IntVar(&opt.Quality, "quality", 85, "Quality of the image")
flag.BoolVar(&opt.NoCrop, "nocrop", false, "Disable cropping")
flag.StringVar(&opt.Algo, "algo", "default", fmt.Sprintf("Algo for RGB to Grayscale: %s", strings.Join(availableAlgo, ", ")))
flag.IntVar(&opt.LimitMb, "limitmb", 0, "Limit size of the ePub: Default nolimit (0), Minimum 20")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage of %s:\n", filepath.Base(os.Args[0]))
flag.PrintDefaults()
}
flag.Parse()
if opt.Input == "" {
fmt.Fprintln(os.Stderr, "Missing input or output!")
flag.Usage()
os.Exit(1)
}
if cmd.Options.Json {
_ = json.NewEncoder(os.Stdout).Encode(map[string]any{
"type": "options", "data": cmd.Options,
})
var defaultOutput string
fi, err := os.Stat(opt.Input)
if err != nil {
fmt.Fprintln(os.Stderr, err)
flag.Usage()
os.Exit(1)
}
inputBase := filepath.Clean(opt.Input)
if fi.IsDir() {
defaultOutput = fmt.Sprintf("%s.epub", inputBase)
} else {
utils.Println(cmd.Options)
ext := filepath.Ext(inputBase)
defaultOutput = fmt.Sprintf("%s.epub", inputBase[0:len(inputBase)-len(ext)])
}
if err := epub.New(cmd.Options.EPUBOptions).Write(); err != nil {
utils.Fatalf("Error: %v\n", err)
if opt.Output == "" {
opt.Output = defaultOutput
}
if !cmd.Options.Dry {
cmd.Stats()
if filepath.Ext(opt.Output) != ".epub" {
fo, err := os.Stat(opt.Output)
if err != nil {
fmt.Fprintln(os.Stderr, err)
flag.Usage()
os.Exit(1)
}
if !fo.IsDir() {
fmt.Fprintln(os.Stderr, "output must be an existing dir or end with .epub")
flag.Usage()
os.Exit(1)
}
opt.Output = filepath.Join(
opt.Output,
filepath.Base(defaultOutput),
)
}
profileIdx, profileMatch := ProfilesIdx[opt.Profile]
if !profileMatch {
fmt.Fprintln(os.Stderr, "Profile doesn't exists!")
flag.Usage()
os.Exit(1)
}
profile := Profiles[profileIdx]
if opt.LimitMb > 0 && opt.LimitMb < 20 {
fmt.Fprintln(os.Stderr, "LimitMb should be 0 or >= 20")
flag.Usage()
os.Exit(1)
}
if opt.Title == "" {
ext := filepath.Ext(defaultOutput)
opt.Title = filepath.Base(defaultOutput[0 : len(defaultOutput)-len(ext)])
}
if _, ok := imageconverter.AlgoGray[opt.Algo]; !ok {
fmt.Fprintln(os.Stderr, "algo doesn't exists")
flag.Usage()
os.Exit(1)
}
fmt.Fprintln(os.Stderr, opt)
if err := epub.NewEpub(&epub.EpubOptions{
Input: opt.Input,
Output: opt.Output,
LimitMb: opt.LimitMb,
Title: opt.Title,
Author: opt.Author,
ImageOptions: &epub.ImageOptions{
ViewWidth: profile.Width,
ViewHeight: profile.Height,
Quality: opt.Quality,
Crop: !opt.NoCrop,
Algo: opt.Algo,
},
}).Write(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
os.Exit(0)
}

View File

@ -1,509 +0,0 @@
// Package epub Tools to create EPUB from images.
package epub
import (
"archive/zip"
"fmt"
"math"
"path/filepath"
"regexp"
"sort"
"strings"
"text/template"
"time"
"github.com/gofrs/uuid"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/epubimage"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/epubimagepassthrough"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/epubimageprocessor"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/epubprogress"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/epubtemplates"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/epubtree"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/epubzip"
"github.com/celogeek/go-comic-converter/v3/internal/pkg/utils"
"github.com/celogeek/go-comic-converter/v3/pkg/epuboptions"
)
type EPUB interface {
Write() error
}
type epub struct {
epuboptions.EPUBOptions
UID string
Publisher string
UpdatedAt string
templateProcessor *template.Template
imageProcessor epubimageprocessor.EPUBImageProcessor
}
type epubPart struct {
Cover epubimage.EPUBImage
Images []epubimage.EPUBImage
}
// New initialize EPUB
func New(options epuboptions.EPUBOptions) EPUB {
uid := uuid.Must(uuid.NewV4())
tmpl := template.New("parser")
tmpl.Funcs(template.FuncMap{
"mod": func(i, j int) bool { return i%j == 0 },
"zoom": func(s int, z float32) int { return int(float32(s) * z) },
})
var imageProcessor epubimageprocessor.EPUBImageProcessor
if options.Image.Format == "copy" {
imageProcessor = epubimagepassthrough.New(options)
} else {
imageProcessor = epubimageprocessor.New(options)
}
return epub{
EPUBOptions: options,
UID: uid.String(),
Publisher: "GO Comic Converter",
UpdatedAt: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
templateProcessor: tmpl,
imageProcessor: imageProcessor,
}
}
// render templates
func (e epub) render(templateString string, data map[string]any) string {
var result strings.Builder
tmpl := template.Must(e.templateProcessor.Parse(templateString))
if err := tmpl.Execute(&result, data); err != nil {
panic(err)
}
return regexp.MustCompile("\n+").ReplaceAllString(result.String(), "\n")
}
// write image to the zip
func (e epub) writeImage(wz epubzip.EPUBZip, img epubimage.EPUBImage, zipImg *zip.File) error {
err := wz.WriteContent(
img.EPUBPagePath(),
[]byte(e.render(epubtemplates.Text, map[string]any{
"Title": "Image " + utils.IntToString(img.Id) + " Part " + utils.IntToString(img.Part),
"ViewPort": e.Image.View.Port(),
"ImagePath": img.ImgPath(),
"ImageStyle": img.ImgStyle(e.Image.View.Width, e.Image.View.Height, ""),
})),
)
if err == nil {
err = wz.Copy(zipImg)
}
return err
}
// write blank page
func (e epub) writeBlank(wz epubzip.EPUBZip, img epubimage.EPUBImage) error {
return wz.WriteContent(
img.EPUBSpacePath(),
[]byte(e.render(epubtemplates.Blank, map[string]any{
"Title": "Blank Page " + utils.IntToString(img.Id),
"ViewPort": e.Image.View.Port(),
})),
)
}
// write title image
func (e epub) writeCoverImage(wz epubzip.EPUBZip, img epubimage.EPUBImage, part, totalParts int) error {
title := "Cover"
text := ""
if totalParts > 1 {
text = utils.IntToString(part) + " / " + utils.IntToString(totalParts)
title = title + " " + text
}
if err := wz.WriteContent(
"OEBPS/Text/cover.xhtml",
[]byte(e.render(epubtemplates.Text, map[string]any{
"Title": title,
"ViewPort": e.Image.View.Port(),
"ImagePath": "Images/cover.jpeg",
"ImageStyle": img.ImgStyle(e.Image.View.Width, e.Image.View.Height, ""),
})),
); err != nil {
return err
}
coverTitle, err := e.imageProcessor.CoverTitleData(epubimageprocessor.CoverTitleDataOptions{
Src: img.Raw,
Name: "cover",
Text: text,
Align: "bottom",
PctWidth: 50,
PctMargin: 50,
MaxFontSize: 96,
BorderSize: 8,
})
if err != nil {
return err
}
if err := wz.WriteRaw(coverTitle); err != nil {
return err
}
return nil
}
// write title image
func (e epub) writeTitleImage(wz epubzip.EPUBZip, img epubimage.EPUBImage, title string) error {
titleAlign := ""
if !e.Image.View.PortraitOnly {
if e.Image.Manga {
titleAlign = "right:0"
} else {
titleAlign = "left:0"
}
}
if !e.Image.View.PortraitOnly {
if err := wz.WriteContent(
"OEBPS/Text/space_title.xhtml",
[]byte(e.render(epubtemplates.Blank, map[string]any{
"Title": "Blank Page Title",
"ViewPort": e.Image.View.Port(),
})),
); err != nil {
return err
}
}
if err := wz.WriteContent(
"OEBPS/Text/title.xhtml",
[]byte(e.render(epubtemplates.Text, map[string]any{
"Title": title,
"ViewPort": e.Image.View.Port(),
"ImagePath": "Images/title.jpeg",
"ImageStyle": img.ImgStyle(e.Image.View.Width, e.Image.View.Height, titleAlign),
})),
); err != nil {
return err
}
coverTitle, err := e.imageProcessor.CoverTitleData(epubimageprocessor.CoverTitleDataOptions{
Src: img.Raw,
Name: "title",
Text: title,
Align: "center",
PctWidth: 100,
PctMargin: 100,
MaxFontSize: 64,
BorderSize: 4,
})
if err != nil {
return err
}
if err := wz.WriteRaw(coverTitle); err != nil {
return err
}
return nil
}
// extract image and split it into part
func (e epub) getParts() (parts []epubPart, imgStorage epubzip.StorageImageReader, err error) {
images, err := e.imageProcessor.Load()
if err != nil {
return
}
// sort result by id and part
sort.Slice(images, func(i, j int) bool {
if images[i].Id == images[j].Id {
return images[i].Part < images[j].Part
}
return images[i].Id < images[j].Id
})
parts = make([]epubPart, 0)
cover := images[0]
if e.Image.HasCover || (cover.DoublePage && !e.Image.KeepDoublePageIfSplit) {
images = images[1:]
}
if e.Dry {
parts = append(parts, epubPart{
Cover: cover,
Images: images,
})
return
}
imgStorage, err = epubzip.NewStorageImageReader(e.ImgStorage())
if err != nil {
return
}
// compute size of the EPUB part and try to be as close as possible of the target
maxSize := uint64(e.LimitMb * 1024 * 1024)
xhtmlSize := uint64(1024)
// descriptor files + title + cover
baseSize := uint64(128*1024) + imgStorage.Size(cover.EPUBImgPath())*2
currentSize := baseSize
currentImages := make([]epubimage.EPUBImage, 0)
part := 1
for _, img := range images {
imgSize := imgStorage.Size(img.EPUBImgPath()) + xhtmlSize
if maxSize > 0 && len(currentImages) > 0 && currentSize+imgSize > maxSize {
parts = append(parts, epubPart{
Cover: cover,
Images: currentImages,
})
part += 1
currentSize = baseSize
currentImages = make([]epubimage.EPUBImage, 0)
}
currentSize += imgSize
currentImages = append(currentImages, img)
}
if len(currentImages) > 0 {
parts = append(parts, epubPart{
Cover: cover,
Images: currentImages,
})
}
return
}
// create a tree from the directories.
//
// this is used to simulate the toc.
func (e epub) getTree(images []epubimage.EPUBImage, skipFiles bool) string {
t := epubtree.New()
for _, img := range images {
if skipFiles {
t.Add(img.Path)
} else {
t.Add(filepath.Join(img.Path, img.Name))
}
}
c := t.Root()
if skipFiles && e.StripFirstDirectoryFromToc && c.ChildCount() == 1 {
c = c.FirstChild()
}
return c.WriteString("")
}
func (e epub) computeAspectRatio(epubParts []epubPart) float64 {
var (
bestAspectRatio float64
bestAspectRatioCount int
aspectRatio = map[float64]int{}
)
trunc := func(v float64) float64 {
return float64(math.Round(v*10000)) / 10000
}
for _, p := range epubParts {
aspectRatio[trunc(p.Cover.OriginalAspectRatio)]++
for _, i := range p.Images {
aspectRatio[trunc(i.OriginalAspectRatio)]++
}
}
for k, v := range aspectRatio {
if v > bestAspectRatioCount {
bestAspectRatio, bestAspectRatioCount = k, v
}
}
return bestAspectRatio
}
func (e epub) computeViewPort(epubParts []epubPart) (int, int) {
if e.Image.View.AspectRatio == -1 {
//keep device size
return e.Image.View.Width, e.Image.View.Height
}
// readjusting view port
bestAspectRatio := e.Image.View.AspectRatio
if bestAspectRatio == 0 {
bestAspectRatio = e.computeAspectRatio(epubParts)
}
viewWidth, viewHeight := int(float64(e.Image.View.Height)/bestAspectRatio), int(float64(e.Image.View.Width)*bestAspectRatio)
if viewWidth > e.Image.View.Width {
return e.Image.View.Width, viewHeight
} else {
return viewWidth, e.Image.View.Height
}
}
func (e epub) writePart(path string, currentPart, totalParts int, part epubPart, imgStorage epubzip.StorageImageReader) error {
hasTitlePage := e.TitlePage == 1 || (e.TitlePage == 2 && totalParts > 1)
wz, err := epubzip.New(path)
if err != nil {
return err
}
defer func(wz epubzip.EPUBZip) {
_ = wz.Close()
}(wz)
title := e.Title
if totalParts > 1 {
title = title + " [" + utils.IntToString(currentPart) + "/" + utils.IntToString(totalParts) + "]"
}
type zipContent struct {
Name string
Content string
}
content := []zipContent{
{"META-INF/container.xml", epubtemplates.Container},
{"META-INF/com.apple.ibooks.display-options.xml", epubtemplates.AppleBooks},
{"OEBPS/content.opf", epubtemplates.Content{
Title: title,
HasTitlePage: hasTitlePage,
UID: e.UID,
Author: e.Author,
Publisher: e.Publisher,
UpdatedAt: e.UpdatedAt,
ImageOptions: e.Image,
Cover: part.Cover,
Images: part.Images,
Current: currentPart,
Total: totalParts,
}.String()},
{"OEBPS/toc.xhtml", epubtemplates.Toc(title, hasTitlePage, e.StripFirstDirectoryFromToc, part.Images)},
{"OEBPS/Text/style.css", e.render(epubtemplates.Style, map[string]any{
"View": e.Image.View,
})},
}
if err = wz.WriteMagic(); err != nil {
return err
}
for _, c := range content {
if err := wz.WriteContent(c.Name, []byte(c.Content)); err != nil {
return err
}
}
if err = e.writeCoverImage(wz, part.Cover, currentPart, totalParts); err != nil {
return err
}
if hasTitlePage {
if err = e.writeTitleImage(wz, part.Cover, title); err != nil {
return err
}
}
lastImage := part.Images[len(part.Images)-1]
for _, img := range part.Images {
if err := e.writeImage(wz, img, imgStorage.Get(img.EPUBImgPath())); err != nil {
return err
}
// Double Page or Last Image that is not a double page
if !e.Image.View.PortraitOnly &&
(img.DoublePage ||
(!e.Image.KeepDoublePageIfSplit && img.Part == 1) ||
(img.Part == 0 && img == lastImage)) {
if err := e.writeBlank(wz, img); err != nil {
return err
}
}
}
return nil
}
// create the zip
func (e epub) Write() error {
epubParts, imgStorage, err := e.getParts()
if err != nil {
return err
}
if e.Dry {
p := epubParts[0]
utils.Printf("TOC:\n - %s\n%s\n", e.Title, e.getTree(p.Images, true))
if e.DryVerbose {
if e.Image.HasCover {
utils.Printf("Cover:\n%s\n", e.getTree([]epubimage.EPUBImage{p.Cover}, false))
}
utils.Printf("Files:\n%s\n", e.getTree(p.Images, false))
}
return nil
}
defer func() {
_ = imgStorage.Close()
_ = imgStorage.Remove()
}()
totalParts := len(epubParts)
bar := epubprogress.New(epubprogress.Options{
Max: totalParts,
Description: "Writing Part",
CurrentJob: 2,
TotalJob: 2,
Quiet: e.Quiet,
Json: e.Json,
})
e.Image.View.Width, e.Image.View.Height = e.computeViewPort(epubParts)
for i, part := range epubParts {
ext := filepath.Ext(e.Output)
suffix := ""
if totalParts > 1 {
fmtLen := utils.FormatNumberOfDigits(totalParts)
fmtPart := "Part " + fmtLen + " of " + fmtLen
suffix = fmt.Sprintf(fmtPart, i+1, totalParts)
}
path := e.Output[0:len(e.Output)-len(ext)] + suffix + ext
if err := e.writePart(
path,
i+1,
totalParts,
part,
imgStorage,
); err != nil {
return err
}
_ = bar.Add(1)
}
_ = bar.Close()
if !e.Json {
utils.Println()
}
// display corrupted images
hasError := false
for pId, part := range epubParts {
if pId == 0 && e.Image.HasCover && part.Cover.Error != nil {
hasError = true
utils.Printf("Error on image %s: %v\n", filepath.Join(part.Cover.Path, part.Cover.Name), part.Cover.Error)
}
for _, img := range part.Images {
if img.Part == 0 && img.Error != nil {
hasError = true
utils.Printf("Error on image %s: %v\n", filepath.Join(img.Path, img.Name), img.Error)
}
}
}
if hasError {
utils.Println()
}
return nil
}

View File

@ -1,6 +0,0 @@
package epuboptions
type Color struct {
Foreground string `yaml:"foreground" json:"foreground"`
Background string `yaml:"background" json:"background"`
}

View File

@ -1,11 +0,0 @@
package epuboptions
type Crop struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Left int `yaml:"left" json:"left"`
Up int `yaml:"up" json:"up"`
Right int `yaml:"right" json:"right"`
Bottom int `yaml:"bottom" json:"bottom"`
Limit int `yaml:"limit" json:"limit"`
SkipIfLimitReached bool `yaml:"skip_if_limit_reached" json:"skip_if_limit_reached"`
}

View File

@ -1,36 +0,0 @@
// Package epuboptions for EPUB creation.
package epuboptions
type EPUBOptions struct {
// Output
Input string `yaml:"-" json:"input"`
Output string `yaml:"-" json:"output"`
Author string `yaml:"-" json:"author"`
Title string `yaml:"-" json:"title"`
//Config
TitlePage int `yaml:"title_page" json:"title_page"`
LimitMb int `yaml:"limit_mb" json:"limit_mb"`
StripFirstDirectoryFromToc bool `yaml:"strip_first_directory" json:"strip_first_directory"`
SortPathMode int `yaml:"sort_path_mode" json:"sort_path_mode"`
Image Image `yaml:"image" json:"image"`
// Other
Dry bool `yaml:"-" json:"dry"`
DryVerbose bool `yaml:"-" json:"dry_verbose"`
Quiet bool `yaml:"-" json:"-"`
Json bool `yaml:"-" json:"-"`
Workers int `yaml:"-" json:"workers"`
}
func (o EPUBOptions) WorkersRatio(pct int) (nbWorkers int) {
nbWorkers = o.Workers * pct / 100
if nbWorkers < 1 {
nbWorkers = 1
}
return
}
func (o EPUBOptions) ImgStorage() string {
return o.Output + ".tmp"
}

View File

@ -1,22 +0,0 @@
package epuboptions
type Image struct {
Crop Crop `yaml:"crop" json:"crop"`
Quality int `yaml:"quality" json:"quality"`
Brightness int `yaml:"brightness" json:"brightness"`
Contrast int `yaml:"contrast" json:"contrast"`
AutoContrast bool `yaml:"auto_contrast" json:"auto_contrast"`
AutoRotate bool `yaml:"auto_rotate" json:"auto_rotate"`
AutoSplitDoublePage bool `yaml:"auto_split_double_page" json:"auto_split_double_page"`
KeepDoublePageIfSplit bool `yaml:"keep_double_page_if_split" json:"keep_double_page_if_split"`
KeepSplitDoublePageAspect bool `yaml:"keep_split_double_page_aspect" json:"keep_split_double_page_aspect"`
NoBlankImage bool `yaml:"no_blank_image" json:"no_blank_image"`
Manga bool `yaml:"manga" json:"manga"`
HasCover bool `yaml:"has_cover" json:"has_cover"`
View View `yaml:"view" json:"view"`
GrayScale bool `yaml:"grayscale" json:"grayscale"`
GrayScaleMode int `yaml:"grayscale_mode" json:"gray_scale_mode"` // 0 = normal, 1 = average, 2 = luminance
Resize bool `yaml:"resize" json:"resize"`
Format string `yaml:"format" json:"format"`
AppleBookCompatibility bool `yaml:"apple_book_compatibility" json:"apple_book_compatibility"`
}

View File

@ -1,21 +0,0 @@
package epuboptions
import (
"github.com/celogeek/go-comic-converter/v3/internal/pkg/utils"
)
type View struct {
Width int `yaml:"-" json:"width"`
Height int `yaml:"-" json:"height"`
AspectRatio float64 `yaml:"aspect_ratio" json:"aspect_ratio"`
PortraitOnly bool `yaml:"portrait_only" json:"portrait_only"`
Color Color `yaml:"color" json:"color"`
}
func (v View) Dimension() string {
return utils.IntToString(v.Width) + "x" + utils.IntToString(v.Height)
}
func (v View) Port() string {
return "width=" + utils.IntToString(v.Width) + ",height=" + utils.IntToString(v.Height)
}