mirror of
https://github.com/celogeek/go-comic-converter.git
synced 2025-05-25 16:22:37 +02:00
Compare commits
286 Commits
Author | SHA1 | Date | |
---|---|---|---|
c2ea8ff5b9 | |||
b10e9b1808 | |||
14be5a4ea3 | |||
c0bc8ea01a | |||
dfb9a6fff3 | |||
506cd1ad6c | |||
277dff8161 | |||
af261d3f75 | |||
ad614d09b4 | |||
86fbb8cefe | |||
2d21ced2fe | |||
05dd8acc99 | |||
87a127d04a | |||
a5ef2a2927 | |||
673a7df699 | |||
16a12603a0 | |||
|
d1a14d3489 | ||
aabea6fb12 | |||
c34a255768 | |||
b1a398e155 | |||
36512cdb98 | |||
45677c6b7b | |||
4aafa7d57e | |||
db9ab4d743 | |||
cc7dfc8d54 | |||
e435397bc0 | |||
c9df70fbb3 | |||
68f7bf4f46 | |||
fee8a037dc | |||
7e3f0d4fcc | |||
0c09c85763 | |||
47205b16be | |||
291c8983a4 | |||
2755aae487 | |||
0c8e54f0c4 | |||
93294ba1f9 | |||
cdd3668ca2 | |||
30ac67b06d | |||
577d51a819 | |||
a76f29e91d | |||
3c582ae52d | |||
a351106eb7 | |||
3d31108aba | |||
a94e67dd06 | |||
5404390fd8 | |||
b1e246240c | |||
e10eeadc4e | |||
5c7775cd47 | |||
113e7ebcb0 | |||
2ce29d62ec | |||
84863e72d2 | |||
87faac877c | |||
62fc520ebb | |||
27d617640a | |||
f9ebe71b9e | |||
f7f8680eb3 | |||
2c372594cc | |||
5e5a07c930 | |||
897efdc1d1 | |||
7b304149fd | |||
cc7a97ad6f | |||
5968cbdd09 | |||
79e20ca622 | |||
8233d067ef | |||
f4501753c5 | |||
0dde6e02a4 | |||
c500755a4e | |||
9133493e60 | |||
c75c58dede | |||
ee466f2872 | |||
ab62692ce6 | |||
1ff7912b0f | |||
2990367e2f | |||
23388b5f30 | |||
ea85d820eb | |||
139acd864b | |||
07ba350d82 | |||
84a7666b13 | |||
39d746bd45 | |||
ca73ac9682 | |||
b6db09aa9e | |||
2d15bc43c2 | |||
58ea72ce82 | |||
0a60bff6b8 | |||
56943aa96c | |||
51f62588c2 | |||
2442f2edc7 | |||
e90a5d8be3 | |||
efdd74f216 | |||
6c92597d8c | |||
1b08a6ad10 | |||
c0abe841b0 | |||
fc6c894394 | |||
a552d7ac90 | |||
4a3fc60732 | |||
77d6600a36 | |||
cd73416a5c | |||
21cbde14bc | |||
7ea9b9f86e | |||
56917987ae | |||
51d157b310 | |||
5320d6a480 | |||
7e098326be | |||
777106aaa2 | |||
|
24d8cd1268 | ||
|
67157a1a35 | ||
9bcbb44c07 | |||
fdca0caae7 | |||
eb6c0e77f0 | |||
9a1d62c583 | |||
1e6e0af7ce | |||
f11fc7e86a | |||
f3eab7503a | |||
7de6ad1508 | |||
eb7dd3efb6 | |||
7ce25bb234 | |||
2650b926f8 | |||
a54e093844 | |||
0de6cc9acd | |||
4a1ae516ea | |||
003a91a07e | |||
5b301526e1 | |||
bd8506d367 | |||
d7e311488f | |||
4d9681b457 | |||
bb15fa5538 | |||
88928168b4 | |||
abf3b8facf | |||
728129aba2 | |||
a816350c97 | |||
58cd5f5399 | |||
ee00ed2615 | |||
13103b0eba | |||
8b04cbc38f | |||
1c1c1fd38c | |||
94983b0c61 | |||
bcad0389fa | |||
5b0051720f | |||
5ec963a11e | |||
edfa349b01 | |||
e6051fda88 | |||
141aeae624 | |||
3858583728 | |||
05ac50a453 | |||
b71deb257a | |||
8cf02445e7 | |||
8ba526f186 | |||
1119101f65 | |||
e8613b0cce | |||
1bc85f4c2d | |||
fd796be892 | |||
341fd2649e | |||
0fdbf5baca | |||
12cd279bb8 | |||
06cc6d2788 | |||
42eba9cb3b | |||
ba538b431b | |||
66d6c22e55 | |||
6295fefa02 | |||
02f86eb55e | |||
4553e1e673 | |||
721d373a7f | |||
2b7582e776 | |||
4dc1f7275f | |||
a1b7497f0c | |||
0637d64b93 | |||
0263a64321 | |||
b268521494 | |||
d0d9af7f9b | |||
8a1eeb400d | |||
01d1670edd | |||
7eebd18538 | |||
07e04e514b | |||
dbec9b81e8 | |||
9da0887334 | |||
402e450aca | |||
b340305f33 | |||
58ed44c28d | |||
4e4245fd21 | |||
56dcfc27f1 | |||
a150128e73 | |||
2444dfee3b | |||
1ad8f9ddd0 | |||
3ae2489fa5 | |||
7a4cc1cec5 | |||
42c58c733b | |||
aae62f62c3 | |||
0014ba7d86 | |||
9c93b4f1d4 | |||
82aa66c363 | |||
5960c56938 | |||
e79cd346c4 | |||
9cf733b553 | |||
a3851475d2 | |||
418d51662c | |||
88eea07747 | |||
51c04fecb0 | |||
d96e63bc50 | |||
b27a826cd2 | |||
4a43760535 | |||
bc19e5e4ce | |||
7f195a0b79 | |||
a2eeda8479 | |||
17d8161d99 | |||
02583cc715 | |||
8d24f95579 | |||
a7a29d6326 | |||
ed1a312027 | |||
b9115f7f6f | |||
5276f2ba6f | |||
afc3355e0d | |||
|
d883b9b54c | ||
0d74d60b3d | |||
333124d4e5 | |||
bb5659277b | |||
34ed40e26c | |||
07381d1f71 | |||
50203b9804 | |||
fbb48830d4 | |||
105c21f8d2 | |||
42368e1980 | |||
4cc329af38 | |||
a224c64568 | |||
deea0657bb | |||
b1eae59324 | |||
a46b02d38a | |||
01b0486966 | |||
|
d9c817f27e | ||
c1b67a250e | |||
7a4821d43d | |||
aeb5034a43 | |||
bcfd342026 | |||
0e9ae95df0 | |||
a24bf0cfc8 | |||
05a936947a | |||
a3d6e43b83 | |||
04d1629bd0 | |||
e9e614ffac | |||
615903a8cd | |||
1dcbe4468d | |||
d470338d2b | |||
49e3d6c1d9 | |||
ede00654b2 | |||
5905b3eeb7 | |||
5ed7e32955 | |||
3fd1529bb1 | |||
49056e71d9 | |||
db4ba5ad5f | |||
6bd46fc64c | |||
d6c761ec6c | |||
2a2cb3341d | |||
0c248d1ac8 | |||
6cf507c678 | |||
9738f7ba37 | |||
a331d0fcb5 | |||
9964f9b00c | |||
8a77bda99e | |||
393bba0266 | |||
6f74f959fd | |||
ef8e88c56b | |||
0360e297b8 | |||
cf0cb2ea89 | |||
84e4d32f72 | |||
f0673b8f58 | |||
6dc220d0f4 | |||
21b099ab33 | |||
f1fd7a9b77 | |||
5f661aec57 | |||
eef33a4c40 | |||
259bf6bb7b | |||
e8f806141e | |||
dbb93e2f73 | |||
0f3d55279e | |||
421a5ef5a7 | |||
6c904ad70b | |||
7adea5ddbd | |||
991e95f02e | |||
54c156625c | |||
0df631edad | |||
4b434f7416 | |||
58f8123c9e | |||
49e7f27a59 | |||
3524ce6d04 | |||
8475faa648 | |||
e8c028d276 | |||
793383cdfd |
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
/tmp/
|
7
LICENSE.txt
Normal file
7
LICENSE.txt
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
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.
|
585
README.md
585
README.md
@ -1,10 +1,36 @@
|
|||||||
# go-comic-converter
|
# 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, crossplatform, and fast tool to convert comics into epub.
|
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.
|
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
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
@ -12,41 +38,80 @@ First ensure to have a working version of GO: [Installation](https://go.dev/doc/
|
|||||||
|
|
||||||
Then install the last version of the tool:
|
Then install the last version of the tool:
|
||||||
```
|
```
|
||||||
go install github.com/celogeek/go-comic-converter@latest
|
$ go install github.com/celogeek/go-comic-converter/v3
|
||||||
```
|
```
|
||||||
|
|
||||||
To force install a specific version:
|
To force install a specific version:
|
||||||
```
|
```
|
||||||
go install github.com/celogeek/go-comic-converter@TAG
|
# specific version
|
||||||
# Ex: go install github.com/celogeek/go-comic-converter@v1.0.0
|
$ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
Add GOPATH to your PATH
|
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
|
# Usage
|
||||||
|
|
||||||
## Convert directory
|
## Convert directory
|
||||||
|
|
||||||
Convert every ".jpg" file found in the input directory:
|
Convert every supported image files found in the input directory:
|
||||||
|
|
||||||
```
|
```
|
||||||
go-comic-converter --profile KS --input ~/Download/MyComic
|
$ go-comic-converter -profile SR -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 CBZ, ZIP, CBR, RAR, PDF
|
||||||
|
|
||||||
Convert every ".jpg" file found in the input directory:
|
Convert every supported image files found in the input directory:
|
||||||
|
|
||||||
```
|
```
|
||||||
go-comic-converter --profile KS --input ~/Download/MyComic.[CBZ,ZIP,CBR,RAR,PDF]
|
$ go-comic-converter -profile SR -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
|
## Convert with size limit
|
||||||
|
|
||||||
@ -55,69 +120,473 @@ If you send your ePub through Amazon service, you have some size limitation:
|
|||||||
- App : 50Mb
|
- App : 50Mb
|
||||||
- Website: 200Mb
|
- 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 KS --input ~/Download/MyComic.[CBZ,ZIP,CBR,RAR,PDF] --limitmb 200
|
go-comic-converter -profile SR -input ~/Download/MyComic.[CBZ,ZIP,CBR,RAR,PDF] -limitmb 200
|
||||||
```
|
```
|
||||||
|
|
||||||
If you have more than 1 file the output will be:
|
If you have more than 1 file the output will be:
|
||||||
- ~/Download/MyComic PART_01.epub
|
- ~/Download/MyComic Part 01 of 03.epub
|
||||||
- ~/Download/MyComic PART_02.epub
|
- ~/Download/MyComic Part 02 of 03.epub
|
||||||
- ...
|
- ...
|
||||||
|
|
||||||
The ePub include as a first page:
|
The ePub include as a first page:
|
||||||
- Title
|
- Title
|
||||||
- Part NUM / TOTAL
|
- 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
|
# Help
|
||||||
|
|
||||||
```
|
```
|
||||||
# go-comic-converter -h
|
$ go-comic-converter -h
|
||||||
|
|
||||||
Usage of go-comic-converter:
|
Usage of go-comic-converter:
|
||||||
-algo string
|
|
||||||
Algo for RGB to Grayscale: default, mean, luma, luster (default "default")
|
Output:
|
||||||
-author string
|
|
||||||
Author of the epub (default "GO Comic Converter")
|
|
||||||
-input string
|
-input string
|
||||||
Source of comic to convert: directory, cbz, zip, cbr, rar, pdf
|
Source of comic to convert: directory, cbz, zip, cbr, rar, pdf
|
||||||
-limitmb int
|
|
||||||
Limit size of the ePub: Default nolimit (0), Minimum 20
|
|
||||||
-nocrop
|
|
||||||
Disable cropping
|
|
||||||
-output string
|
-output string
|
||||||
Output of the epub (directory or epub): (default [INPUT].epub)
|
Output of the EPUB (directory or EPUB): (default [INPUT].epub)
|
||||||
-profile string
|
-author string (default "GO Comic Converter")
|
||||||
Profile to use:
|
Author of the EPUB
|
||||||
- K1 ( 600x670 ) - 4 levels of gray - Kindle 1
|
|
||||||
- K11 ( 1072x1448 ) - 16 levels of gray - Kindle 11
|
|
||||||
- K2 ( 600x670 ) - 15 levels of gray - Kindle 2
|
|
||||||
- K34 ( 600x800 ) - 16 levels of gray - Kindle Keyboard/Touch
|
|
||||||
- K578 ( 600x800 ) - 16 levels of gray - Kindle
|
|
||||||
- KDX ( 824x1000 ) - 16 levels of gray - Kindle DX/DXG
|
|
||||||
- KPW ( 758x1024 ) - 16 levels of gray - Kindle Paperwhite 1/2
|
|
||||||
- KV ( 1072x1448 ) - 16 levels of gray - Kindle Paperwhite 3/4/Voyage/Oasis
|
|
||||||
- KPW5 ( 1236x1648 ) - 16 levels of gray - Kindle Paperwhite 5/Signature Edition
|
|
||||||
- KO ( 1264x1680 ) - 16 levels of gray - Kindle Oasis 2/3
|
|
||||||
- KS ( 1860x2480 ) - 16 levels of gray - Kindle Scribe
|
|
||||||
- KoMT ( 600x800 ) - 16 levels of gray - Kobo Mini/Touch
|
|
||||||
- KoG ( 768x1024 ) - 16 levels of gray - Kobo Glo
|
|
||||||
- KoGHD ( 1072x1448 ) - 16 levels of gray - Kobo Glo HD
|
|
||||||
- KoA ( 758x1024 ) - 16 levels of gray - Kobo Aura
|
|
||||||
- KoAHD ( 1080x1440 ) - 16 levels of gray - Kobo Aura HD
|
|
||||||
- KoAH2O ( 1080x1430 ) - 16 levels of gray - Kobo Aura H2O
|
|
||||||
- KoAO ( 1404x1872 ) - 16 levels of gray - Kobo Aura ONE
|
|
||||||
- KoN ( 758x1024 ) - 16 levels of gray - Kobo Nia
|
|
||||||
- KoC ( 1072x1448 ) - 16 levels of gray - Kobo Clara HD/Kobo Clara 2E
|
|
||||||
- KoL ( 1264x1680 ) - 16 levels of gray - Kobo Libra H2O/Kobo Libra 2
|
|
||||||
- KoF ( 1440x1920 ) - 16 levels of gray - Kobo Forma
|
|
||||||
- KoS ( 1440x1920 ) - 16 levels of gray - Kobo Sage
|
|
||||||
- KoE ( 1404x1872 ) - 16 levels of gray - Kobo Elipsa
|
|
||||||
-quality int
|
|
||||||
Quality of the image (default 85)
|
|
||||||
-title string
|
-title string
|
||||||
Title of the epub
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
# Credit
|
# Credit
|
||||||
@ -126,3 +595,7 @@ This project is largely inspired from KCC (Kindle Comic Converter). Thanks:
|
|||||||
- [ciromattia](https://github.com/ciromattia/kcc)
|
- [ciromattia](https://github.com/ciromattia/kcc)
|
||||||
- [darodi fork](https://github.com/darodi/kcc)
|
- [darodi fork](https://github.com/darodi/kcc)
|
||||||
|
|
||||||
|
# UI
|
||||||
|
|
||||||
|
Thanks for UI contribution:
|
||||||
|
- [manueldidonna / Comic2Books](https://github.com/manueldidonna/comic2books)
|
||||||
|
29
go.mod
29
go.mod
@ -1,19 +1,28 @@
|
|||||||
module github.com/celogeek/go-comic-converter
|
module github.com/celogeek/go-comic-converter/v3
|
||||||
|
|
||||||
go 1.19
|
go 1.23
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gofrs/uuid v4.3.1+incompatible
|
github.com/beevik/etree v1.5.0
|
||||||
github.com/nwaples/rardecode v1.1.3
|
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/raff/pdfreader v0.0.0-20220308062436-033e8ac577f0
|
github.com/raff/pdfreader v0.0.0-20220308062436-033e8ac577f0
|
||||||
github.com/schollz/progressbar/v3 v3.12.2
|
github.com/schollz/progressbar/v3 v3.18.0
|
||||||
golang.org/x/image v0.2.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
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/mattn/go-runewidth v0.0.14 // indirect
|
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/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||||
github.com/rivo/uniseg v0.4.3 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
golang.org/x/sys v0.3.0 // indirect
|
golang.org/x/net v0.35.0 // indirect
|
||||||
golang.org/x/term v0.3.0 // indirect
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
|
golang.org/x/term v0.29.0 // indirect
|
||||||
)
|
)
|
||||||
|
93
go.sum
93
go.sum
@ -1,56 +1,53 @@
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI=
|
github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
|
||||||
github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
|
||||||
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw=
|
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||||
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
|
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
||||||
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
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/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
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/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
|
||||||
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
|
github.com/nwaples/rardecode/v2 v2.1.0 h1:JQl9ZoBPDy+nIZGb1mx8+anfHp/LV3NE2MjMiv0ct/U=
|
||||||
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
github.com/nwaples/rardecode/v2 v2.1.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
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/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 h1:fuFvfwIc+cpySYurvDNTs5LIHXP9Cj3reVRplj9Whv4=
|
||||||
github.com/raff/pdfreader v0.0.0-20220308062436-033e8ac577f0/go.mod h1:Ql3QqeGiYGlPOtYz+F/L7J27spqDcdH9LhDHOrrdsD4=
|
github.com/raff/pdfreader v0.0.0-20220308062436-033e8ac577f0/go.mod h1:Ql3QqeGiYGlPOtYz+F/L7J27spqDcdH9LhDHOrrdsD4=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
|
||||||
github.com/schollz/progressbar/v3 v3.12.2 h1:yLqqqpQNMxGxHY8uEshRihaHWwa0rf0yb7/Zrpgq2C0=
|
github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
|
||||||
github.com/schollz/progressbar/v3 v3.12.2/go.mod h1:HFJYIYQQJX32UJdyoigUl19xoV6aMwZt6iX/C30RWfg=
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e h1:IWllFTiDjjLIf2oeKxpIUmtiDV5sn71VgeQgg6vcE7k=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e/go.mod h1:d7u6HkTYKSv5m6MCKkOQlHwaShTMl3HjqSGW3XtVhXM=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
golang.org/x/image v0.2.0 h1:/DcQ0w3VHKCC5p0/P2B0JpAZ9Z++V2KOo2fyU89CXBQ=
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
golang.org/x/image v0.2.0/go.mod h1:la7oBXb9w3YFjBqaAwtynVioc1ZvOnNteUNrifGNmAI=
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
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=
|
|
||||||
|
@ -1,196 +0,0 @@
|
|||||||
package epub
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"image/color"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"text/template"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gofrs/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ImageOptions struct {
|
|
||||||
Crop bool
|
|
||||||
ViewWidth int
|
|
||||||
ViewHeight int
|
|
||||||
Quality int
|
|
||||||
Algo string
|
|
||||||
Palette color.Palette
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
@ -1,44 +0,0 @@
|
|||||||
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(),
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,317 +0,0 @@
|
|||||||
package epub
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/celogeek/go-comic-converter/internal/imageconverter"
|
|
||||||
|
|
||||||
"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,
|
|
||||||
options.Palette,
|
|
||||||
)
|
|
||||||
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
|
|
||||||
}
|
|
@ -1,31 +0,0 @@
|
|||||||
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: "]",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,24 +0,0 @@
|
|||||||
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
|
|
@ -1,6 +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>
|
|
@ -1,43 +0,0 @@
|
|||||||
<?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>
|
|
@ -1,20 +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>
|
|
||||||
<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>
|
|
@ -1,15 +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>
|
|
||||||
<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>
|
|
@ -1,83 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
@ -1,40 +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>
|
|
||||||
<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>
|
|
@ -1,14 +0,0 @@
|
|||||||
<?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>
|
|
@ -1,83 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
@ -1,29 +0,0 @@
|
|||||||
package imageconverter
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image/color"
|
|
||||||
"sort"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ALGO_GRAY = map[string]func(color.Color, color.Palette) color.Gray{
|
|
||||||
"default": func(c color.Color, p color.Palette) color.Gray {
|
|
||||||
return p.Convert(c).(color.Gray)
|
|
||||||
},
|
|
||||||
"mean": func(c color.Color, p color.Palette) color.Gray {
|
|
||||||
r, g, b, _ := c.RGBA()
|
|
||||||
y := float64(r+g+b) / 3
|
|
||||||
return p.Convert(color.Gray16{Y: uint16(y)}).(color.Gray)
|
|
||||||
},
|
|
||||||
"luma": func(c color.Color, p color.Palette) color.Gray {
|
|
||||||
r, g, b, _ := c.RGBA()
|
|
||||||
y := (0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(b))
|
|
||||||
return p.Convert(color.Gray16{Y: uint16(y)}).(color.Gray)
|
|
||||||
},
|
|
||||||
"luster": func(c color.Color, p color.Palette) color.Gray {
|
|
||||||
r, g, b, _ := c.RGBA()
|
|
||||||
arr := []float64{float64(r), float64(g), float64(b)}
|
|
||||||
sort.Float64s(arr)
|
|
||||||
y := (arr[0] + arr[2]) / 2
|
|
||||||
return p.Convert(color.Gray16{Y: uint16(y)}).(color.Gray)
|
|
||||||
},
|
|
||||||
}
|
|
@ -1,134 +0,0 @@
|
|||||||
package imageconverter
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"image"
|
|
||||||
"image/color"
|
|
||||||
"image/jpeg"
|
|
||||||
"io"
|
|
||||||
|
|
||||||
"golang.org/x/image/draw"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Load(reader io.ReadCloser, algo string, palette color.Palette) *image.Gray {
|
|
||||||
defer reader.Close()
|
|
||||||
img, _, err := image.Decode(reader)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
algoFunc, ok := ALGO_GRAY[algo]
|
|
||||||
if !ok {
|
|
||||||
panic("unknown algo")
|
|
||||||
}
|
|
||||||
|
|
||||||
grayImg := image.NewGray(img.Bounds())
|
|
||||||
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.SetGray(x, y, algoFunc(img.At(x, y), palette))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return grayImg
|
|
||||||
}
|
|
||||||
|
|
||||||
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, palette color.Palette) ([]byte, int, int) {
|
|
||||||
img := Load(reader, algo, palette)
|
|
||||||
if crop {
|
|
||||||
img = CropMarging(img)
|
|
||||||
}
|
|
||||||
img = Resize(img, w, h)
|
|
||||||
return Get(img, quality), img.Bounds().Dx(), img.Bounds().Dy()
|
|
||||||
}
|
|
@ -1,47 +0,0 @@
|
|||||||
package imageconverter
|
|
||||||
|
|
||||||
import "image/color"
|
|
||||||
|
|
||||||
var PALETTE_16 = color.Palette{
|
|
||||||
color.Gray{0x00},
|
|
||||||
color.Gray{0x11},
|
|
||||||
color.Gray{0x22},
|
|
||||||
color.Gray{0x33},
|
|
||||||
color.Gray{0x44},
|
|
||||||
color.Gray{0x55},
|
|
||||||
color.Gray{0x66},
|
|
||||||
color.Gray{0x77},
|
|
||||||
color.Gray{0x88},
|
|
||||||
color.Gray{0x99},
|
|
||||||
color.Gray{0xaa},
|
|
||||||
color.Gray{0xbb},
|
|
||||||
color.Gray{0xcc},
|
|
||||||
color.Gray{0xdd},
|
|
||||||
color.Gray{0xee},
|
|
||||||
color.Gray{0xff},
|
|
||||||
}
|
|
||||||
|
|
||||||
var PALETTE_15 = color.Palette{
|
|
||||||
color.Gray{0x00},
|
|
||||||
color.Gray{0x11},
|
|
||||||
color.Gray{0x22},
|
|
||||||
color.Gray{0x33},
|
|
||||||
color.Gray{0x44},
|
|
||||||
color.Gray{0x55},
|
|
||||||
color.Gray{0x66},
|
|
||||||
color.Gray{0x77},
|
|
||||||
color.Gray{0x88},
|
|
||||||
color.Gray{0x99},
|
|
||||||
color.Gray{0xaa},
|
|
||||||
color.Gray{0xbb},
|
|
||||||
color.Gray{0xcc},
|
|
||||||
color.Gray{0xdd},
|
|
||||||
color.Gray{0xff},
|
|
||||||
}
|
|
||||||
|
|
||||||
var PALETTE_4 = color.Palette{
|
|
||||||
color.Gray{0x00},
|
|
||||||
color.Gray{0x55},
|
|
||||||
color.Gray{0xaa},
|
|
||||||
color.Gray{0xff},
|
|
||||||
}
|
|
437
internal/pkg/converter/converter.go
Normal file
437
internal/pkg/converter/converter.go
Normal file
@ -0,0 +1,437 @@
|
|||||||
|
// 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
251
internal/pkg/converter/options.go
Normal file
251
internal/pkg/converter/options.go
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
// 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()
|
||||||
|
}
|
27
internal/pkg/converter/order.go
Normal file
27
internal/pkg/converter/order.go
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
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
|
||||||
|
}
|
77
internal/pkg/converter/profiles.go
Normal file
77
internal/pkg/converter/profiles.go
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
// 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")
|
||||||
|
}
|
130
internal/pkg/epubimage/epub_image.go
Normal file
130
internal/pkg/epubimage/epub_image.go
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
// 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
|
||||||
|
}
|
90
internal/pkg/epubimagefilters/auto_contrast.go
Normal file
90
internal/pkg/epubimagefilters/auto_contrast.go
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
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
|
||||||
|
}
|
123
internal/pkg/epubimagefilters/auto_crop.go
Normal file
123
internal/pkg/epubimagefilters/auto_crop.go
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
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
|
||||||
|
}
|
98
internal/pkg/epubimagefilters/cover_title.go
Normal file
98
internal/pkg/epubimagefilters/cover_title.go
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
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))
|
||||||
|
}
|
38
internal/pkg/epubimagefilters/crop_split_double_page.go
Normal file
38
internal/pkg/epubimagefilters/crop_split_double_page.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
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)
|
||||||
|
}
|
36
internal/pkg/epubimagefilters/pixel.go
Normal file
36
internal/pkg/epubimagefilters/pixel.go
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
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)
|
||||||
|
}
|
432
internal/pkg/epubimagepassthrough/passthrough.go
Normal file
432
internal/pkg/epubimagepassthrough/passthrough.go
Normal file
@ -0,0 +1,432 @@
|
|||||||
|
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
|
||||||
|
}
|
431
internal/pkg/epubimageprocessor/loader.go
Normal file
431
internal/pkg/epubimageprocessor/loader.go
Normal file
@ -0,0 +1,431 @@
|
|||||||
|
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
|
||||||
|
}
|
333
internal/pkg/epubimageprocessor/processor.go
Normal file
333
internal/pkg/epubimageprocessor/processor.go
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
// 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,
|
||||||
|
)
|
||||||
|
}
|
66
internal/pkg/epubprogress/epub_progress.go
Normal file
66
internal/pkg/epubprogress/epub_progress.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
// 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: "]",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
33
internal/pkg/epubprogress/json.go
Normal file
33
internal/pkg/epubprogress/json.go
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
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
|
||||||
|
}
|
6
internal/pkg/epubtemplates/applebooks.go
Normal file
6
internal/pkg/epubtemplates/applebooks.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package epubtemplates
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
//go:embed "applebooks.xml.tmpl"
|
||||||
|
var AppleBooks string
|
8
internal/pkg/epubtemplates/applebooks.xml.tmpl
Normal file
8
internal/pkg/epubtemplates/applebooks.xml.tmpl
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?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>
|
7
internal/pkg/epubtemplates/blank.go
Normal file
7
internal/pkg/epubtemplates/blank.go
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
// Package epubtemplates Templates use to create xml files of the EPUB.
|
||||||
|
package epubtemplates
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
//go:embed "blank.xhtml.tmpl"
|
||||||
|
var Blank string
|
12
internal/pkg/epubtemplates/blank.xhtml.tmpl
Normal file
12
internal/pkg/epubtemplates/blank.xhtml.tmpl
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?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>
|
6
internal/pkg/epubtemplates/container.go
Normal file
6
internal/pkg/epubtemplates/container.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package epubtemplates
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
//go:embed "container.xml.tmpl"
|
||||||
|
var Container string
|
7
internal/pkg/epubtemplates/container.xml.tmpl
Normal file
7
internal/pkg/epubtemplates/container.xml.tmpl
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?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>
|
282
internal/pkg/epubtemplates/content.go
Normal file
282
internal/pkg/epubtemplates/content.go
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
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()}, ""},
|
||||||
|
}
|
||||||
|
}
|
19
internal/pkg/epubtemplates/style.css.tmpl
Normal file
19
internal/pkg/epubtemplates/style.css.tmpl
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
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;
|
||||||
|
}
|
6
internal/pkg/epubtemplates/style.go
Normal file
6
internal/pkg/epubtemplates/style.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package epubtemplates
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
//go:embed "style.css.tmpl"
|
||||||
|
var Style string
|
6
internal/pkg/epubtemplates/text.go
Normal file
6
internal/pkg/epubtemplates/text.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package epubtemplates
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
//go:embed "text.xhtml.tmpl"
|
||||||
|
var Text string
|
13
internal/pkg/epubtemplates/text.xhtml.tmpl
Normal file
13
internal/pkg/epubtemplates/text.xhtml.tmpl
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?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>
|
74
internal/pkg/epubtemplates/toc.go
Normal file
74
internal/pkg/epubtemplates/toc.go
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
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
|
||||||
|
}
|
83
internal/pkg/epubtree/epub_tree.go
Normal file
83
internal/pkg/epubtree/epub_tree.go
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
/*
|
||||||
|
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()
|
||||||
|
}
|
91
internal/pkg/epubzip/epub_zip.go
Normal file
91
internal/pkg/epubzip/epub_zip.go
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
/*
|
||||||
|
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
|
||||||
|
}
|
104
internal/pkg/epubzip/image.go
Normal file
104
internal/pkg/epubzip/image.go
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
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
|
||||||
|
}
|
53
internal/pkg/epubzip/storage_image_reader.go
Normal file
53
internal/pkg/epubzip/storage_image_reader.go
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
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)
|
||||||
|
}
|
73
internal/pkg/epubzip/storage_image_writer.go
Normal file
73
internal/pkg/epubzip/storage_image_writer.go
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
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
|
||||||
|
}
|
51
internal/pkg/sortpath/by.go
Normal file
51
internal/pkg/sortpath/by.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
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}
|
||||||
|
}
|
83
internal/pkg/sortpath/parser.go
Normal file
83
internal/pkg/sortpath/parser.go
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
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))
|
||||||
|
}
|
56
internal/pkg/utils/utils.go
Normal file
56
internal/pkg/utils/utils.go
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
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"
|
||||||
|
}
|
62
internal/pkg/utils/utils_test.go
Normal file
62
internal/pkg/utils/utils_test.go
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
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
|
||||||
|
}
|
330
main.go
330
main.go
@ -1,234 +1,134 @@
|
|||||||
|
/*
|
||||||
|
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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"image/color"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"runtime/debug"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/celogeek/go-comic-converter/internal/imageconverter"
|
"github.com/tcnksm/go-latest"
|
||||||
|
|
||||||
"github.com/celogeek/go-comic-converter/internal/epub"
|
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Profile struct {
|
func main() {
|
||||||
Code string
|
cmd := converter.New()
|
||||||
Description string
|
if err := cmd.LoadConfig(); err != nil {
|
||||||
Width int
|
cmd.Fatal(err)
|
||||||
Height int
|
|
||||||
Palette color.Palette
|
|
||||||
}
|
|
||||||
|
|
||||||
var Profiles = []Profile{
|
|
||||||
// Kindle
|
|
||||||
{"K1", "Kindle 1", 600, 670, imageconverter.PALETTE_4},
|
|
||||||
{"K11", "Kindle 11", 1072, 1448, imageconverter.PALETTE_16},
|
|
||||||
{"K2", "Kindle 2", 600, 670, imageconverter.PALETTE_15},
|
|
||||||
{"K34", "Kindle Keyboard/Touch", 600, 800, imageconverter.PALETTE_16},
|
|
||||||
{"K578", "Kindle", 600, 800, imageconverter.PALETTE_16},
|
|
||||||
{"KDX", "Kindle DX/DXG", 824, 1000, imageconverter.PALETTE_16},
|
|
||||||
{"KPW", "Kindle Paperwhite 1/2", 758, 1024, imageconverter.PALETTE_16},
|
|
||||||
{"KV", "Kindle Paperwhite 3/4/Voyage/Oasis", 1072, 1448, imageconverter.PALETTE_16},
|
|
||||||
{"KPW5", "Kindle Paperwhite 5/Signature Edition", 1236, 1648, imageconverter.PALETTE_16},
|
|
||||||
{"KO", "Kindle Oasis 2/3", 1264, 1680, imageconverter.PALETTE_16},
|
|
||||||
{"KS", "Kindle Scribe", 1860, 2480, imageconverter.PALETTE_16},
|
|
||||||
// Kobo
|
|
||||||
{"KoMT", "Kobo Mini/Touch", 600, 800, imageconverter.PALETTE_16},
|
|
||||||
{"KoG", "Kobo Glo", 768, 1024, imageconverter.PALETTE_16},
|
|
||||||
{"KoGHD", "Kobo Glo HD", 1072, 1448, imageconverter.PALETTE_16},
|
|
||||||
{"KoA", "Kobo Aura", 758, 1024, imageconverter.PALETTE_16},
|
|
||||||
{"KoAHD", "Kobo Aura HD", 1080, 1440, imageconverter.PALETTE_16},
|
|
||||||
{"KoAH2O", "Kobo Aura H2O", 1080, 1430, imageconverter.PALETTE_16},
|
|
||||||
{"KoAO", "Kobo Aura ONE", 1404, 1872, imageconverter.PALETTE_16},
|
|
||||||
{"KoN", "Kobo Nia", 758, 1024, imageconverter.PALETTE_16},
|
|
||||||
{"KoC", "Kobo Clara HD/Kobo Clara 2E", 1072, 1448, imageconverter.PALETTE_16},
|
|
||||||
{"KoL", "Kobo Libra H2O/Kobo Libra 2", 1264, 1680, imageconverter.PALETTE_16},
|
|
||||||
{"KoF", "Kobo Forma", 1440, 1920, imageconverter.PALETTE_16},
|
|
||||||
{"KoS", "Kobo Sage", 1440, 1920, imageconverter.PALETTE_16},
|
|
||||||
{"KoE", "Kobo Elipsa", 1404, 1872, imageconverter.PALETTE_16},
|
|
||||||
}
|
|
||||||
var ProfilesIdx = map[string]int{}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
for i, p := range Profiles {
|
|
||||||
ProfilesIdx[p.Code] = i
|
|
||||||
}
|
}
|
||||||
}
|
cmd.InitParse()
|
||||||
|
cmd.Parse()
|
||||||
|
|
||||||
type Option struct {
|
switch {
|
||||||
Input string
|
case cmd.Options.Version:
|
||||||
Output string
|
version()
|
||||||
Profile string
|
case cmd.Options.Save:
|
||||||
Author string
|
save(cmd)
|
||||||
Title string
|
case cmd.Options.Show:
|
||||||
Quality int
|
show(cmd)
|
||||||
NoCrop bool
|
case cmd.Options.Reset:
|
||||||
Algo string
|
reset(cmd)
|
||||||
LimitMb int
|
default:
|
||||||
}
|
generate(cmd)
|
||||||
|
|
||||||
func (o *Option) String() string {
|
|
||||||
var desc string
|
|
||||||
var width, height, level int
|
|
||||||
if i, ok := ProfilesIdx[o.Profile]; ok {
|
|
||||||
profile := Profiles[i]
|
|
||||||
desc = profile.Description
|
|
||||||
width = profile.Width
|
|
||||||
height = profile.Height
|
|
||||||
level = len(profile.Palette)
|
|
||||||
}
|
|
||||||
limitmb := "nolimit"
|
|
||||||
if o.LimitMb > 0 {
|
|
||||||
limitmb = fmt.Sprintf("%d Mb", o.LimitMb)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf(`Go Comic Converter
|
}
|
||||||
|
|
||||||
Options:
|
func version() {
|
||||||
Input : %s
|
bi, ok := debug.ReadBuildInfo()
|
||||||
Output : %s
|
if !ok {
|
||||||
Profile : %s - %s - %dx%d - %d levels of gray
|
utils.Fatalln("failed to fetch current version")
|
||||||
Author : %s
|
}
|
||||||
Title : %s
|
|
||||||
Quality : %d
|
githubTag := &latest.GithubTag{
|
||||||
Crop : %v
|
Owner: "celogeek",
|
||||||
Algo : %s
|
Repository: "go-comic-converter",
|
||||||
LimitMb : %s
|
}
|
||||||
|
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]
|
||||||
|
|
||||||
|
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
|
||||||
`,
|
`,
|
||||||
o.Input,
|
bi.Main.Path,
|
||||||
o.Output,
|
bi.Main.Sum,
|
||||||
o.Profile, desc, width, height, level,
|
bi.Main.Version,
|
||||||
o.Author,
|
latestVersion.Original(),
|
||||||
o.Title,
|
latestVersion.Segments()[0],
|
||||||
o.Quality,
|
latestVersion.Original(),
|
||||||
!o.NoCrop,
|
|
||||||
o.Algo,
|
|
||||||
limitmb,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func save(cmd *converter.Converter) {
|
||||||
availableProfiles := make([]string, 0)
|
if err := cmd.Options.SaveConfig(); err != nil {
|
||||||
for _, p := range Profiles {
|
cmd.Fatal(err)
|
||||||
availableProfiles = append(availableProfiles, fmt.Sprintf(
|
}
|
||||||
" - %-7s ( %9s ) - %2d levels of gray - %s",
|
utils.Printf(
|
||||||
p.Code,
|
"%s%s\n\nSaving to %s\n",
|
||||||
fmt.Sprintf("%dx%d", p.Width, p.Height),
|
cmd.Options.Header(),
|
||||||
len(p.Palette),
|
cmd.Options.ShowConfig(),
|
||||||
p.Description,
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if profile := cmd.Options.GetProfile(); profile != nil {
|
||||||
|
cmd.Options.Image.View.Width = profile.Width
|
||||||
|
cmd.Options.Image.View.Height = profile.Height
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd.Options.Json {
|
||||||
|
_ = json.NewEncoder(os.Stdout).Encode(map[string]any{
|
||||||
|
"type": "options", "data": cmd.Options,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
utils.Println(cmd.Options)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := epub.New(cmd.Options.EPUBOptions).Write(); err != nil {
|
||||||
|
utils.Fatalf("Error: %v\n", err)
|
||||||
|
}
|
||||||
|
if !cmd.Options.Dry {
|
||||||
|
cmd.Stats()
|
||||||
}
|
}
|
||||||
availableAlgo := make([]string, 0)
|
|
||||||
for a := range imageconverter.ALGO_GRAY {
|
|
||||||
availableAlgo = append(availableAlgo, a)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
ext := filepath.Ext(inputBase)
|
|
||||||
defaultOutput = fmt.Sprintf("%s.epub", inputBase[0:len(inputBase)-len(ext)])
|
|
||||||
}
|
|
||||||
|
|
||||||
if opt.Output == "" {
|
|
||||||
opt.Output = defaultOutput
|
|
||||||
}
|
|
||||||
|
|
||||||
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.ALGO_GRAY[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,
|
|
||||||
Palette: profile.Palette,
|
|
||||||
},
|
|
||||||
}).Write(); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
}
|
||||||
|
509
pkg/epub/epub.go
Normal file
509
pkg/epub/epub.go
Normal file
@ -0,0 +1,509 @@
|
|||||||
|
// 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
|
||||||
|
}
|
6
pkg/epuboptions/color.go
Normal file
6
pkg/epuboptions/color.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package epuboptions
|
||||||
|
|
||||||
|
type Color struct {
|
||||||
|
Foreground string `yaml:"foreground" json:"foreground"`
|
||||||
|
Background string `yaml:"background" json:"background"`
|
||||||
|
}
|
11
pkg/epuboptions/crop.go
Normal file
11
pkg/epuboptions/crop.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
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"`
|
||||||
|
}
|
36
pkg/epuboptions/epub_options.go
Normal file
36
pkg/epuboptions/epub_options.go
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
// 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"
|
||||||
|
}
|
22
pkg/epuboptions/image.go
Normal file
22
pkg/epuboptions/image.go
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
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"`
|
||||||
|
}
|
21
pkg/epuboptions/view.go
Normal file
21
pkg/epuboptions/view.go
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
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)
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user