Compare commits

..

234 Commits
v2.0.3 ... main

Author SHA1 Message Date
c2ea8ff5b9
hide processing params for passthrough 2025-02-21 10:51:38 +01:00
b10e9b1808
update readme 2025-02-21 10:46:02 +01:00
14be5a4ea3
Merge branch 'passthrough' 2025-02-21 10:41:14 +01:00
c0bc8ea01a
improve code 2025-02-21 10:40:51 +01:00
dfb9a6fff3
separate passthrough from processor 2025-02-17 09:22:42 +01:00
506cd1ad6c
passthrough, support rar/cbr 2025-02-16 18:14:37 +01:00
277dff8161
passthrough, support zip/cbz, factor 2025-02-16 17:42:13 +01:00
af261d3f75
passthrough, add png support for dir reading 2025-02-16 16:55:57 +01:00
ad614d09b4
passthrough, support for dir 2025-02-16 12:50:25 +01:00
86fbb8cefe
add raw data to zip 2025-02-16 12:50:01 +01:00
2d21ced2fe
add compress raw data 2025-02-16 12:49:48 +01:00
05dd8acc99
no image is handled in loader 2025-02-16 12:49:37 +01:00
87a127d04a
use epubimage format instead of global params 2025-02-16 12:49:08 +01:00
a5ef2a2927
add format copy option 2025-02-15 18:29:12 +01:00
673a7df699
force cover and title to use jpeg format 2025-02-15 17:28:42 +01:00
16a12603a0
update deps 2025-02-15 16:50:11 +01:00
PuzzleMoon
d1a14d3489 css img object-fit to preserve aspect ratio 2025-01-29 17:58:17 +01:00
aabea6fb12
ignore hidden files 2025-01-29 17:48:08 +01:00
c34a255768
26 expose the internal epub and epub options package (#44)
* make epub and epub options public

* export only interface
2025-01-05 16:57:23 +01:00
b1a398e155
add upgrade notice 2025-01-05 15:36:36 +01:00
36512cdb98
update to v3 2025-01-05 15:32:57 +01:00
45677c6b7b
Merge branch 'simplify-options' 2024-12-24 11:25:27 +01:00
4aafa7d57e
use directly epub options instead of duplicating it 2024-12-24 11:20:53 +01:00
db9ab4d743
exclude tmp file 2024-12-24 11:19:30 +01:00
cc7dfc8d54
handle error on parse 2024-12-24 10:49:56 +01:00
e435397bc0
update deps 2024-12-24 10:49:56 +01:00
c9df70fbb3
add UI links
https://github.com/celogeek/go-comic-converter/issues/39
2024-10-27 15:06:09 +01:00
68f7bf4f46
update to golang 1.23 + deps 2024-10-12 15:57:06 +02:00
fee8a037dc
fix position 2024-06-22 12:21:05 +02:00
7e3f0d4fcc
fix doc 2024-06-22 11:56:29 +02:00
0c09c85763
fix aspect ratio computing 2024-06-22 11:56:19 +02:00
47205b16be
use fatalf instead 2024-06-07 11:18:16 +02:00
291c8983a4
handle double page as cover if split and not keeped 2024-05-18 16:23:41 +02:00
2755aae487
combine print and exit 1 to fatal 2024-05-18 16:16:29 +02:00
0c8e54f0c4
limit usage of Sprint 2024-05-13 13:27:34 +02:00
93294ba1f9
Merge branch 'reorg-packaging' 2024-05-12 08:19:11 +02:00
cdd3668ca2
improve readability main 2024-05-11 15:44:47 +02:00
30ac67b06d
move to pkg 2024-05-11 15:30:52 +02:00
577d51a819
rename epub 2024-05-11 14:31:27 +02:00
a76f29e91d
remove pointer from main 2024-05-11 14:27:27 +02:00
3c582ae52d
remove pointer from epub processor 2024-05-11 14:27:09 +02:00
a351106eb7
remove pointer from epub template 2024-05-11 14:26:59 +02:00
3d31108aba
remove pointer from epub zip 2024-05-11 14:26:51 +02:00
a94e67dd06
remove pointer from epub filters 2024-05-11 14:26:04 +02:00
5404390fd8
remove pointer from epub image 2024-05-11 14:25:52 +02:00
b1e246240c
remove pointer from profiles 2024-05-11 14:25:20 +02:00
e10eeadc4e
remove pointer from image options 2024-05-10 18:10:07 +02:00
5c7775cd47
simplify profiles 2024-05-10 18:05:51 +02:00
113e7ebcb0
update deps 2024-05-10 14:08:16 +02:00
2ce29d62ec
update README 2024-05-09 15:59:04 +02:00
84863e72d2
Merge branch '34-spacexhtml-bug-in-split-page-mode' 2024-05-09 15:51:45 +02:00
87faac877c
add option to not preserve aspect on split double page 2024-05-09 15:14:54 +02:00
62fc520ebb
cut more each side of doublepage split in portrait only 2024-05-09 12:28:48 +02:00
27d617640a
detect empty page 2024-05-08 18:49:01 +02:00
f9ebe71b9e
crop double page after autocrop
fix landscape display when each side can be cut differently.
A doublepage is 1 big image, and should be cut as one.
2024-05-07 08:55:42 +02:00
f7f8680eb3
fix doublePage issue when part crop are also a double page 2024-05-06 17:24:31 +02:00
2c372594cc
support tiff 2024-05-01 12:44:54 +02:00
5e5a07c930
update README 2024-04-28 22:10:13 +02:00
897efdc1d1
Merge branch '32-add-limts-to-crop-page' 2024-04-28 21:37:39 +02:00
7b304149fd
write string directly 2024-04-28 21:36:40 +02:00
cc7a97ad6f
handle missing profile 2024-04-28 21:36:26 +02:00
5968cbdd09
sort import 2024-04-28 21:36:08 +02:00
79e20ca622
skip deprecated 2024-04-28 21:35:48 +02:00
8233d067ef
handle errors 2024-04-28 21:35:36 +02:00
f4501753c5
factor stderr printing 2024-04-28 19:36:49 +02:00
0dde6e02a4
add skip crop if limit reached 2024-04-28 19:28:35 +02:00
c500755a4e
add limit to crop limit 2024-04-28 17:45:02 +02:00
9133493e60
use percentage for cropping 2024-04-28 17:40:37 +02:00
c75c58dede
add crop limit option 2024-04-27 17:57:49 +02:00
ee466f2872
update deps 2024-04-27 17:55:58 +02:00
ab62692ce6
update to go1.22 2024-02-26 14:12:47 +01:00
1ff7912b0f
handle error 2024-02-10 00:07:21 +02:00
2990367e2f
fix export type 2024-02-09 20:20:15 +02:00
23388b5f30
fix defer in a loop 2024-02-09 18:52:57 +02:00
ea85d820eb
fix grammar 2024-02-09 18:36:10 +02:00
139acd864b
fix naming 2024-02-09 10:46:20 +02:00
07ba350d82
fix comments 2024-02-08 21:05:27 +02:00
84a7666b13
fix overlapping name 2024-02-08 20:56:06 +02:00
39d746bd45
update deps 2024-02-08 20:55:48 +02:00
ca73ac9682
rename splitted to split 2024-02-08 20:49:35 +02:00
b6db09aa9e
fix typo & grammar 2024-02-08 20:44:00 +02:00
2d15bc43c2
update deps 2024-02-06 17:38:41 +02:00
58ea72ce82
Merge pull request #29 from celogeek/28-split-double-page-with-crop-enable
28 split double page with crop enable
2024-02-06 16:30:17 +01:00
0a60bff6b8
reorder transform image for split & crop support 2024-01-28 16:45:47 +01:00
56943aa96c
remove plural from tasks 2024-01-28 16:44:31 +01:00
51f62588c2
add boundaries for autocrop to allow split then crop 2024-01-28 16:43:54 +01:00
2442f2edc7
remove unuse attribute 2024-01-28 16:43:28 +01:00
e90a5d8be3
ignore vscode config 2024-01-28 16:42:39 +01:00
efdd74f216
update deps 2024-01-27 09:57:46 +01:00
6c92597d8c
update deps 2024-01-01 16:50:17 +01:00
1b08a6ad10
update deps 2023-11-26 10:05:33 +01:00
c0abe841b0
set elapse in ms 2023-09-30 20:27:53 +02:00
fc6c894394
update readme 2023-09-30 20:23:12 +02:00
a552d7ac90
add json output 2023-09-30 20:20:20 +02:00
4a3fc60732
add throttling and interface 2023-09-25 09:10:46 +02:00
77d6600a36
add apple book compatibility mode 2023-09-24 13:40:41 +02:00
cd73416a5c
update readme 2023-09-23 09:55:44 +02:00
21cbde14bc
add option keepdoublepageifsplitted 2023-09-23 09:51:47 +02:00
7ea9b9f86e
update deps 2023-09-23 09:49:05 +02:00
56917987ae
update deps 2023-08-05 15:00:30 +02:00
51d157b310
update README 2023-08-05 14:55:22 +02:00
5320d6a480
add reMarkable 1 2023-08-05 14:54:57 +02:00
7e098326be
set SR as default profile 2023-08-05 14:54:24 +02:00
777106aaa2
Merge pull request #18 from Locutus-code/remarkable2
Add profile for the reMarkable2 ePaper tablet
2023-08-05 14:47:29 +02:00
Locutus
24d8cd1268 Fixed review comments 2023-07-27 20:33:32 +03:00
Locutus
67157a1a35 Add profile for the reMarkable2 ePaper tablet 2023-07-25 21:22:31 +03:00
9bcbb44c07
Merge pull request #17 from celogeek/report-corrupted-images
Report corrupted images
2023-07-08 11:33:34 +02:00
fdca0caae7
record corrupted images and display it at the end 2023-07-08 11:32:37 +02:00
eb6c0e77f0
add gg deps 2023-07-08 11:29:19 +02:00
9a1d62c583
update readme 2023-06-12 09:22:13 +02:00
1e6e0af7ce
upgrade deps 2023-06-11 12:44:43 +02:00
f11fc7e86a
update deps 2023-05-18 18:16:59 +02:00
f3eab7503a
add standard resolution 1920x1200 2023-05-18 18:15:25 +02:00
7de6ad1508
increase base size to avoid reaching the limit 2023-05-10 08:48:51 +02:00
eb7dd3efb6
update deps 2023-05-09 18:11:41 +02:00
7ce25bb234
create auto contrast filter
This filter improve contrast
and make the comics more
readable
2023-05-09 17:40:09 +02:00
2650b926f8
add grayscale mode
it will apply different formula to convert image into grayscale.

it works only if the source is not already in grayscale.

0 = normal
1 = average
2 = luminance
2023-05-05 12:24:12 +02:00
a54e093844
title page settings
add option to include or not title page
0 = never
1 = always
2 = when the epub is splitted
2023-05-05 12:08:48 +02:00
0de6cc9acd
cover improvement
include part in cover if totalParts > 1
cleaner cover in grayscale
2023-05-05 11:39:44 +02:00
4a1ae516ea
move title generation into method 2023-05-05 09:03:26 +02:00
003a91a07e
Merge pull request #14 from celogeek/portrait-only-mode
Portrait only mode
2023-05-04 22:20:22 +02:00
5b301526e1
mode portrait only 2023-05-04 22:18:36 +02:00
bd8506d367
add option to portrait only mode 2023-05-04 19:36:26 +02:00
d7e311488f
Merge pull request #13 from celogeek/compute-aspect-ratio
Compute aspect ratio
2023-05-04 19:28:47 +02:00
4d9681b457
higher precision 2023-05-04 19:26:55 +02:00
bb15fa5538
add option to choose aspect ratio 2023-05-04 17:22:19 +02:00
88928168b4
compute original aspect ratio 2023-05-04 16:56:30 +02:00
abf3b8facf
add shortcut 2023-05-01 19:27:51 +02:00
728129aba2
reorg params and display relevant params only 2023-05-01 19:08:26 +02:00
a816350c97
add png output images format 2023-05-01 18:59:05 +02:00
58cd5f5399
add nofilter option 2023-05-01 17:25:19 +02:00
ee00ed2615
option to keep skip resize filter 2023-05-01 17:09:47 +02:00
13103b0eba
do not enlarge smaller images
all width/height are computed based on viewport dimension.
images will enlarge if smaller, and resize to fit if larger.
2023-05-01 14:11:58 +02:00
8b04cbc38f
support grayscale or color mode
grayscale is on by default
as the conversion is mainly
useful for eInk device.
2023-04-30 18:20:54 +02:00
1c1c1fd38c
rename high resolution profile 2023-04-30 18:12:29 +02:00
94983b0c61
add customization for body color 2023-04-30 16:49:55 +02:00
bcad0389fa
improve alignment on spread images for landscape 2023-04-30 16:25:02 +02:00
5b0051720f
simplify options display 2023-04-30 14:10:27 +02:00
5ec963a11e
change perfect ratio to 1.6 2023-04-30 13:51:06 +02:00
edfa349b01
add high resolution profile 2023-04-30 13:50:36 +02:00
e6051fda88
update readme 2023-04-30 01:10:55 +02:00
141aeae624
show stats only if process images 2023-04-30 00:58:26 +02:00
3858583728
use tmp storage to limit memory usage 2023-04-30 00:37:26 +02:00
05ac50a453
display elapse and memory usage at the end 2023-04-29 23:58:26 +02:00
b71deb257a
change Epub, epub to EPUB in code and comment 2023-04-29 11:55:13 +02:00
8cf02445e7
update readme 2023-04-29 11:54:32 +02:00
8ba526f186
add helper for layout blank 2023-04-28 18:01:32 +02:00
1119101f65
fix space require detection 2023-04-28 17:49:29 +02:00
e8613b0cce
add space before title in landscape 2023-04-28 17:49:16 +02:00
1bc85f4c2d
change noblankpage by noblankimage
detect blank image even if crop is disabled
2023-04-28 17:25:43 +02:00
fd796be892
fix double page render
if spread is used, we need 2 pages when left or right are used

if that case we add a special layout blank to fill the gap
2023-04-28 13:41:18 +02:00
341fd2649e
remove blur on title image 2023-04-28 13:17:35 +02:00
0fdbf5baca
improve image processor 2023-04-27 19:00:27 +02:00
12cd279bb8
use img helper 2023-04-27 18:33:04 +02:00
06cc6d2788
fix duplicate blank page if last page is double 2023-04-27 18:32:48 +02:00
42eba9cb3b
move options to common package 2023-04-27 18:08:32 +02:00
ba538b431b
move compress image to zip package 2023-04-27 17:20:37 +02:00
66d6c22e55
improve cover title 2023-04-27 15:58:40 +02:00
6295fefa02
move image transformation to processing 2023-04-27 15:04:52 +02:00
02f86eb55e
move load to options as an helper 2023-04-27 13:46:00 +02:00
4553e1e673
decode image into loader 2023-04-27 12:02:47 +02:00
721d373a7f
move all filters 2023-04-26 23:04:31 +02:00
2b7582e776
move auto crop to filters 2023-04-26 20:56:02 +02:00
4dc1f7275f
improve crop ratio 2023-04-26 18:22:33 +02:00
a1b7497f0c
improve crop 2023-04-26 17:38:09 +02:00
0637d64b93
fix quiet mode 2023-04-26 17:37:41 +02:00
0263a64321
comments the code 2023-04-26 16:23:38 +02:00
b268521494
auto rotate if src width > src height 2023-04-26 15:58:42 +02:00
d0d9af7f9b
specify type for write file 2023-04-25 22:10:35 +02:00
8a1eeb400d
factor error code 2023-04-25 21:41:19 +02:00
01d1670edd
fix method 2023-04-25 21:02:24 +02:00
7eebd18538
fix autosplit double page
only check source width > height
2023-04-25 20:07:55 +02:00
07e04e514b
improve error handling 2023-04-25 11:15:33 +02:00
dbec9b81e8
improve buffer usage 2023-04-25 11:15:23 +02:00
9da0887334
epubtree hide struc 2023-04-25 10:34:56 +02:00
402e450aca
move templates for content and toc 2023-04-25 10:15:19 +02:00
b340305f33
move image processing 2023-04-24 18:52:45 +02:00
58ed44c28d
create cover title filter 2023-04-24 16:25:10 +02:00
4e4245fd21
reorg epub package 2023-04-24 10:47:04 +02:00
56dcfc27f1
remove compare export 2023-04-23 14:10:47 +02:00
a150128e73
move tree 2023-04-23 14:10:35 +02:00
2444dfee3b
move epub image data and epub zip 2023-04-23 14:07:31 +02:00
1ad8f9ddd0
move templates 2023-04-23 12:16:54 +02:00
3ae2489fa5
update readme 2023-04-22 21:25:50 +02:00
7a4cc1cec5
Merge pull request #10 from celogeek/fix-rendering
Fix rendering
2023-04-22 21:19:44 +02:00
42c58c733b
move create title 2023-04-22 21:16:51 +02:00
aae62f62c3
center title 2023-04-22 21:11:30 +02:00
0014ba7d86
put title alone on the left always 2023-04-22 19:13:50 +02:00
9c93b4f1d4
create title page 2023-04-22 19:07:53 +02:00
82aa66c363
include title for epubpart 2023-04-22 16:53:45 +02:00
5960c56938
create title page 2023-04-22 16:34:18 +02:00
e79cd346c4
fix toc 2023-04-22 15:52:37 +02:00
9cf733b553
add quiet mode 2023-04-22 15:42:39 +02:00
a3851475d2
fix duplicate cover 2023-04-22 15:26:03 +02:00
418d51662c
positioning image instead of creating view size image 2023-04-22 12:14:49 +02:00
88eea07747
rename files 2023-04-22 10:57:50 +02:00
51c04fecb0
display perfect dim 2023-04-22 10:53:15 +02:00
d96e63bc50
improve style 2023-04-22 10:29:36 +02:00
b27a826cd2
improve positioning 2023-04-22 10:28:53 +02:00
4a43760535
add at least one child in the toc 2023-04-22 10:26:11 +02:00
bc19e5e4ce
remove ibook meta, not used 2023-04-22 10:25:09 +02:00
7f195a0b79
use ratio 1.5 for perfect both side rendering 2023-04-22 10:24:47 +02:00
a2eeda8479
use gift filters to create aligned images 2023-04-16 18:26:40 +02:00
17d8161d99
fix filter double page 2023-04-16 18:02:55 +02:00
02583cc715
factor rendering 2023-04-16 18:02:31 +02:00
8d24f95579
simplify css 2023-04-16 15:19:57 +02:00
a7a29d6326
rebuild toc and content
handle landscape mode
better handling double page with epub 3.3 center spread support
improve support for apple book
2023-04-16 11:47:57 +02:00
ed1a312027
remove panelview 2023-04-16 11:44:03 +02:00
b9115f7f6f
add etree to build xml 2023-04-16 11:37:40 +02:00
5276f2ba6f
improve fmt part, using consistant size 2023-04-11 17:35:14 +02:00
afc3355e0d
Merge pull request #9 from yoanhg421/patch-1
Update filename to show total parts when using size limit.
2023-04-11 17:25:18 +02:00
yoanhg421
d883b9b54c
Update filename to show total parts when using size limit. 2023-04-11 06:25:28 -07:00
0d74d60b3d
update meta and nav 2023-04-10 14:59:07 +02:00
333124d4e5
update readme 2023-04-10 12:02:13 +02:00
bb5659277b
use 8bits gray scale instead of palette
Palette limit the number of color to 16.
But kindle seems to handle more.

Remove the palette, and let the kindle handle the shade of gray
2023-04-10 11:53:53 +02:00
34ed40e26c
update readme 2023-04-09 19:57:49 +02:00
07381d1f71
improve dry render
add cover file
use tree and don't skip any struct for file render
fix toc render
2023-04-09 19:52:26 +02:00
50203b9804
fix space for getTree 2023-04-09 18:40:22 +02:00
fbb48830d4
improve dry and sort
dry verbose list files in sorted order
sort support 3 mode
	mode 0: path=alpha, file=alpha
	mode 1: path=alphanum, file=alpha
	mode 2: path=alphanum, file=alphanum
improve alphanum sort, supporting double page like "p51-52"
2023-04-09 18:29:58 +02:00
105c21f8d2
update readme 2023-04-09 08:32:43 +02:00
42368e1980
update deps 2023-04-09 08:32:10 +02:00
4cc329af38
sortpath: support half chapter 2023-04-09 08:23:18 +02:00
a224c64568
update version 2023-04-08 21:08:08 +02:00
deea0657bb
update readme 2023-04-08 21:05:57 +02:00
b1eae59324 Merge pull request 'improve-sorting' (#1) from improve-sorting into main
Reviewed-on: #1
2023-04-08 20:56:32 +02:00
a46b02d38a
use sort path to support path with number 2023-04-08 20:54:50 +02:00
01b0486966
create sortpath 2023-04-08 19:27:05 +02:00
darodi
d9c817f27e
webp as supported image 2023-04-08 19:22:22 +02:00
c1b67a250e
Merge branch 'toc' 2023-04-08 16:23:15 +02:00
7a4821d43d
display toc on dry mode 2023-04-08 13:16:16 +02:00
aeb5034a43
use stderr 2023-04-08 13:12:13 +02:00
bcfd342026
remove last page from toc
this is already include in kindle
2023-04-08 11:28:25 +02:00
0e9ae95df0
add option to strip first directory from toc 2023-04-08 11:27:57 +02:00
a24bf0cfc8
fill nav files to get toc 2023-04-07 18:29:42 +02:00
69 changed files with 4790 additions and 2264 deletions

3
.gitignore vendored
View File

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

580
README.md
View File

@ -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,17 +38,19 @@ 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/v2 $ 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/v2@V2TAG # specific version
``` $ go install github.com/celogeek/go-comic-converter/v3@v3.0.0
Example: # main branch
``` $ go install github.com/celogeek/go-comic-converter/v3@main
$ go install github.com/celogeek/go-comic-converter/v2@v2.0.1
# specific commit
$ go install github.com/celogeek/go-comic-converter/v3@COMMIT_HASH
``` ```
Add GOPATH to your PATH Add GOPATH to your PATH
@ -30,29 +58,39 @@ 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 # Check last version
You can check if a new version is available with: You can check if a new version is available with:
``` ```
$ go-comic-converter -version $ go-comic-converter -version
go-comic-converter go-comic-converter
Path : github.com/celogeek/go-comic-converter/v2 Path : github.com/celogeek/go-comic-converter/v3
Sum : ... Sum : h1:tUFF2m/fGlOJOwC0/PlTopMfcBMprKvgr6TiQHQxEeo=
Version : v2.0.2 Version : v3.0.0
Available Version: v2.0.2 Available Version: v3.0.0
To install the latest version: To install the latest version:
$ go install github.com/celogeek/go-comic-converter/v2@v2.0.2 $ go install github.com/celogeek/go-comic-converter/v3@v3.0.0
``` ```
# Supported image files # Supported image files
The supported image files are jpeg and png from the sources. The supported image files are jpeg and png from the sources.
The extensions can be: `jpg`, `jpeg`, `png`. The extensions can be: `jpg`, `jpeg`, `png`, `webp`, `tiff`.
The case for extensions doesn't matter. 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
@ -60,20 +98,20 @@ The case for extensions doesn't matter.
Convert every supported image files 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 supported image files 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
@ -85,47 +123,130 @@ If you send your ePub through Amazon service, you have some size limitation:
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: If the total is above 1, then the title of the EPUB include:
- Title [part/total] - Title [part/total]
## Dry run ## Dry run
If you want to preview what will be set during the convertion without running the conversion, then you can use the `-dry` option. 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 KS -auto -manga -limitmb 200 -dry $ go-comic-converter -input ~/Downloads/mymanga.cbr -profile SR -auto -manga -limitmb 200 -dry
Go Comic Converter Go Comic Converter
Options: Options:
Input : ~/Downloads/mymanga.cbr Input : ~/Downloads/mymanga.cbr
Output : ~/Downloads/mymanga.epub Output : ~/Downloads/mymanga.epub
Author : GO Comic Converter Author : GO Comic Converter
Title : mymanga Title : mymanga
Workers : 8 Workers : 20
Profile : KS - Kindle Scribe - 1860x2480 - 16 levels of gray Profile : SR - Standard Resolution - 1200x1920
Quality : 85 Format : jpeg
Crop : true Quality : 85
Brightness : 0 Grayscale : true
Contrast : 0 Grayscale mode : normal
AutoRotate : true Crop : true
AutoSplitDoublePage: true Crop ratio : 1 Left - 1 Up - 1 Right - 3 Bottom - Limit 0% - Skip false
NoBlankPage : false Auto contrast : true
Manga : true Auto rotate : true
HasCover : true Auto split double page : true
AddPanelView : false Keep double page if split : true
LimitMb : 200 Mb 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 ## Change default settings
@ -137,39 +258,62 @@ $ go-comic-converter -show
Go Comic Converter Go Comic Converter
Options: Options:
Profile : Profile : SR - Standard Resolution - 1200x1920
Quality : 85 Format : jpeg
Crop : true Quality : 85
Brightness : 0 Grayscale : true
Contrast : 0 Grayscale mode : normal
AutoRotate : false Crop : true
AutoSplitDoublePage: false Crop ratio : 1 Left - 1 Up - 1 Right - 3 Bottom - Limit 0% - Skip false
NoBlankPage : false Auto contrast : false
Manga : false Auto rotate : false
HasCover : true Auto split double page : false
AddPanelView : false No blank image : true
LimitMb : nolimit 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 ### Change default settings
``` ```
$ go-comic-converter -manga -auto -profile KS -limitmb 200 -save $ go-comic-converter -manga -auto -profile SR -limitmb 200 -save
Go Comic Converter Go Comic Converter
Options: Options:
Profile : KS - Kindle Scribe - 1860x2480 - 16 levels of gray Profile : SR - Standard Resolution - 1200x1920
Quality : 85 Format : jpeg
Crop : true Quality : 85
Brightness : 0 Grayscale : true
Contrast : 0 Grayscale mode : normal
AutoRotate : true Crop : true
AutoSplitDoublePage: true Crop ratio : 1 Left - 1 Up - 1 Right - 3 Bottom - Limit 0% - Skip false
NoBlankPage : false Auto contrast : true
Manga : true Auto rotate : true
HasCover : true Auto split double page : true
AddPanelView : false Keep double page if split : true
LimitMb : 200 Mb 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 Saving to ~/.go-comic-converter.yaml
``` ```
@ -178,52 +322,34 @@ If you want to change a setting, you can change only one of them
``` ```
$ go-comic-converter -manga=0 -save $ go-comic-converter -manga=0 -save
Go Comic Converter
Options: Options:
Profile : KS - Kindle Scribe - 1860x2480 - 16 levels of gray Profile : SR - Standard Resolution - 1200x1920
Quality : 85 Format : jpeg
Crop : true Quality : 85
Brightness : 0 Grayscale : true
Contrast : 0 Grayscale mode : normal
AutoRotate : true Crop : true
AutoSplitDoublePage: true Crop ratio : 1 Left - 1 Up - 1 Right - 3 Bottom - Limit 0% - Skip false
NoBlankPage : false Auto contrast : false
Manga : false Auto rotate : false
HasCover : true Auto split double page : false
AddPanelView : false No blank image : true
LimitMb : 200 Mb 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 Saving to ~/.go-comic-converter.yaml
``` ```
### Check ### Reset default
You can test the command dry above like
```
$ go-comic-converter -input ~/Downloads/mymanga.cbr -dry
Go Comic Converter
Options:
Input : ~/Downloads/mymanga.cbr
Output : ~/Downloads/mymanga.epub
Author : GO Comic Converter
Title : mymanga
Workers : 8
Profile : KS - Kindle Scribe - 1860x2480 - 16 levels of gray
Quality : 85
Crop : true
Brightness : 0
Contrast : 0
AutoRotate : true
AutoSplitDoublePage: true
NoBlankPage : false
Manga : true
HasCover : true
AddPanelView : false
LimitMb : 200 Mb
```
### Reset default
To reset all value to default: To reset all value to default:
``` ```
@ -231,22 +357,74 @@ $ go-comic-converter -reset
Go Comic Converter Go Comic Converter
Options: Options:
Profile : Profile : SR - Standard Resolution - 1200x1920
Quality : 85 Format : jpeg
Crop : true Quality : 85
Brightness : 0 Grayscale : true
Contrast : 0 Grayscale mode : normal
AutoRotate : false Crop : true
AutoSplitDoublePage: false Crop ratio : 1 Left - 1 Up - 1 Right - 3 Bottom - Limit 0% - Skip false
NoBlankPage : false Auto contrast : false
Manga : false Auto rotate : false
HasCover : true Auto split double page : false
AddPanelView : false No blank image : true
LimitMb : nolimit 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 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
``` ```
@ -258,67 +436,115 @@ Output:
-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
-output string -output string
Output of the epub (directory or epub): (default [INPUT].epub) Output of the EPUB (directory or EPUB): (default [INPUT].epub)
-author string (default "GO Comic Converter") -author string (default "GO Comic Converter")
Author of the epub Author of the EPUB
-title string -title string
Title of the epub Title of the EPUB
-workers int (default CPU)
Number of workers
-dry
Dry run to show all options
Config: Config:
-profile string -profile string (default "SR")
Profile to use: Profile to use:
- K1 ( 600x670 ) - 4 levels of gray - Kindle 1 - KoAO - 1404 x 1872 - Kobo Aura ONE
- K11 ( 1072x1448 ) - 16 levels of gray - Kindle 11 - KoF - 1440 x 1920 - Kobo Forma
- K2 ( 600x670 ) - 15 levels of gray - Kindle 2 - KoE - 1404 x 1872 - Kobo Elipsa
- K34 ( 600x800 ) - 16 levels of gray - Kindle Keyboard/Touch - KV - 1072 x 1448 - Kindle Paperwhite 3/4/Voyage/Oasis
- K578 ( 600x800 ) - 16 levels of gray - Kindle - KoG - 768 x 1024 - Kobo Glo
- KDX ( 824x1000 ) - 16 levels of gray - Kindle DX/DXG - KoA - 758 x 1024 - Kobo Aura
- KPW ( 758x1024 ) - 16 levels of gray - Kindle Paperwhite 1/2 - RM1 - 1404 x 1872 - reMarkable 1
- KV ( 1072x1448 ) - 16 levels of gray - Kindle Paperwhite 3/4/Voyage/Oasis - RM2 - 1404 x 1872 - reMarkable 2
- KPW5 ( 1236x1648 ) - 16 levels of gray - Kindle Paperwhite 5/Signature Edition - K1 - 600 x 670 - Kindle 1
- KO ( 1264x1680 ) - 16 levels of gray - Kindle Oasis 2/3 - K11 - 1072 x 1448 - Kindle 11
- KS ( 1860x2480 ) - 16 levels of gray - Kindle Scribe - K2 - 600 x 670 - Kindle 2
- KoMT ( 600x800 ) - 16 levels of gray - Kobo Mini/Touch - K34 - 600 x 800 - Kindle Keyboard/Touch
- KoG ( 768x1024 ) - 16 levels of gray - Kobo Glo - KPW5 - 1236 x 1648 - Kindle Paperwhite 5/Signature Edition
- KoGHD ( 1072x1448 ) - 16 levels of gray - Kobo Glo HD - KoAH2O - 1080 x 1430 - Kobo Aura H2O
- KoA ( 758x1024 ) - 16 levels of gray - Kobo Aura - KoN - 758 x 1024 - Kobo Nia
- KoAHD ( 1080x1440 ) - 16 levels of gray - Kobo Aura HD - KoL - 1264 x 1680 - Kobo Libra H2O/Kobo Libra 2
- KoAH2O ( 1080x1430 ) - 16 levels of gray - Kobo Aura H2O - HR - 2400 x 3840 - High Resolution
- KoAO ( 1404x1872 ) - 16 levels of gray - Kobo Aura ONE - KO - 1264 x 1680 - Kindle Oasis 2/3
- KoN ( 758x1024 ) - 16 levels of gray - Kobo Nia - KS - 1860 x 2480 - Kindle Scribe
- KoC ( 1072x1448 ) - 16 levels of gray - Kobo Clara HD/Kobo Clara 2E - KoMT - 600 x 800 - Kobo Mini/Touch
- KoL ( 1264x1680 ) - 16 levels of gray - Kobo Libra H2O/Kobo Libra 2 - KoAHD - 1080 x 1440 - Kobo Aura HD
- KoF ( 1440x1920 ) - 16 levels of gray - Kobo Forma - KoC - 1072 x 1448 - Kobo Clara HD/Kobo Clara 2E
- KoS ( 1440x1920 ) - 16 levels of gray - Kobo Sage - KoS - 1440 x 1920 - Kobo Sage
- KoE ( 1404x1872 ) - 16 levels of gray - Kobo Elipsa - 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 int (default 85)
Quality of the image 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 (default true)
Crop images 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 int
Brightness readjustement: between -100 and 100, > 0 lighter, < 0 darker Brightness readjustment: between -100 and 100, > 0 lighter, < 0 darker
-contrast int -contrast int
Contrast readjustement: between -100 and 100, > 0 more contrast, < 0 less contrast Contrast readjustment: between -100 and 100, > 0 more contrast, < 0 less contrast
-autocontrast
Improve contrast automatically
-autorotate -autorotate
Auto Rotate page when width > height Auto Rotate page when width > height
-auto
Activate all automatic options
-autosplitdoublepage -autosplitdoublepage
Auto Split double page when width > height Auto Split double page when width > height
-noblankpage -keepdoublepageifsplit (default true)
Remove blank pages 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
Manga mode (right to left) Manga mode (right to left)
-hascover (default true) -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. Has cover. Indicate if your comic have a cover. The first page will be used as a cover and include after the title.
-addpanelview
Add an embeded panel view. On kindle you may not need this option as it is handled by the kindle.
-limitmb int -limitmb int
Limit size of the ePub: Default nolimit (0), Minimum 20 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: Default config:
-show -show
@ -328,7 +554,35 @@ Default config:
-reset -reset
Reset your parameters to default 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: 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 -version
Show current and available version Show current and available version
-help -help
@ -341,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)

24
go.mod
View File

@ -1,26 +1,28 @@
module github.com/celogeek/go-comic-converter/v2 module github.com/celogeek/go-comic-converter/v3
go 1.19 go 1.23
require ( require (
github.com/beevik/etree v1.5.0
github.com/disintegration/gift v1.2.1 github.com/disintegration/gift v1.2.1
github.com/fogleman/gg v1.3.0
github.com/gofrs/uuid v4.4.0+incompatible github.com/gofrs/uuid v4.4.0+incompatible
github.com/nwaples/rardecode v1.1.3 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.13.1 github.com/schollz/progressbar/v3 v3.18.0
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e
golang.org/x/image v0.6.0 golang.org/x/image v0.24.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/google/go-github v17.0.0+incompatible // indirect github.com/google/go-github v17.0.0+incompatible // indirect
github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect
github.com/hashicorp/go-version v1.6.0 // indirect github.com/hashicorp/go-version v1.7.0 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/rivo/uniseg v0.4.4 // indirect github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/net v0.6.0 // indirect golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.6.0 // indirect golang.org/x/sys v0.30.0 // indirect
golang.org/x/term v0.6.0 // indirect golang.org/x/term v0.29.0 // indirect
) )

84
go.sum
View File

@ -1,79 +1,51 @@
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/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc= github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 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 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 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 h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY=
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= 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 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db 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.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
github.com/schollz/progressbar/v3 v3.13.1 h1:o8rySDYiQ59Mwzy2FELeHY5ZARXZTVJC7iHD6PEFUiE= github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
github.com/schollz/progressbar/v3 v3.13.1/go.mod h1:xvrbki8kfT1fzWzBT/UZd9L6GA+jdL7HAgq2RFnO6fQ= 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/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e h1:IWllFTiDjjLIf2oeKxpIUmtiDV5sn71VgeQgg6vcE7k= github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e h1:IWllFTiDjjLIf2oeKxpIUmtiDV5sn71VgeQgg6vcE7k=
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e/go.mod h1:d7u6HkTYKSv5m6MCKkOQlHwaShTMl3HjqSGW3XtVhXM= 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.6.0 h1:bR8b5okrPI3g/gyZakLZHeWxAR8Dn5CyxXv1hLH5g/4= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/image v0.6.0/go.mod h1:MXLdDR43H7cDJq5GEGXEVeeNhPgi+YYEQ2pC1byI1x0= 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/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0 h1:L4ZwwTvKW9gr0ZMS1yrHD9GZhIuVjOBBnaKH+SPQK0Q=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
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/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -1,269 +0,0 @@
package converter
import (
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"github.com/celogeek/go-comic-converter/v2/internal/converter/options"
)
type Converter struct {
Options *options.Options
Cmd *flag.FlagSet
order []Order
isZeroValueErrs []error
}
func New() *Converter {
options := options.New()
cmd := flag.NewFlagSet("go-comic-converter", flag.ExitOnError)
conv := &Converter{
Options: options,
Cmd: cmd,
order: make([]Order, 0),
}
cmdOutput := &strings.Builder{}
cmd.SetOutput(cmdOutput)
cmd.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage of %s:\n", filepath.Base(os.Args[0]))
for _, o := range conv.order {
switch v := o.(type) {
case OrderSection:
fmt.Fprintf(os.Stderr, "\n%s:\n", o.Value())
case OrderName:
fmt.Fprintln(os.Stderr, conv.Usage(v.isString, cmd.Lookup(v.Value())))
}
}
if cmdOutput.Len() > 0 {
fmt.Fprintf(os.Stderr, "\nError: %s", cmdOutput.String())
}
}
return conv
}
func (c *Converter) LoadConfig() error {
return c.Options.LoadDefault()
}
func (c *Converter) AddSection(section string) {
c.order = append(c.order, OrderSection{value: section})
}
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})
}
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})
}
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})
}
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.AddIntParam(&c.Options.Workers, "workers", runtime.NumCPU(), "Number of workers")
c.AddBoolParam(&c.Options.Dry, "dry", false, "Dry run to show all options")
c.AddSection("Config")
c.AddStringParam(&c.Options.Profile, "profile", c.Options.Profile, fmt.Sprintf("Profile to use: \n%s", c.Options.AvailableProfiles()))
c.AddIntParam(&c.Options.Quality, "quality", c.Options.Quality, "Quality of the image")
c.AddBoolParam(&c.Options.Crop, "crop", c.Options.Crop, "Crop images")
c.AddIntParam(&c.Options.Brightness, "brightness", c.Options.Brightness, "Brightness readjustement: between -100 and 100, > 0 lighter, < 0 darker")
c.AddIntParam(&c.Options.Contrast, "contrast", c.Options.Contrast, "Contrast readjustement: between -100 and 100, > 0 more contrast, < 0 less contrast")
c.AddBoolParam(&c.Options.AutoRotate, "autorotate", c.Options.AutoRotate, "Auto Rotate page when width > height")
c.AddBoolParam(&c.Options.Auto, "auto", false, "Activate all automatic options")
c.AddBoolParam(&c.Options.AutoSplitDoublePage, "autosplitdoublepage", c.Options.AutoSplitDoublePage, "Auto Split double page when width > height")
c.AddBoolParam(&c.Options.NoBlankPage, "noblankpage", c.Options.NoBlankPage, "Remove blank pages")
c.AddBoolParam(&c.Options.Manga, "manga", c.Options.Manga, "Manga mode (right to left)")
c.AddBoolParam(&c.Options.HasCover, "hascover", c.Options.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.AddBoolParam(&c.Options.AddPanelView, "addpanelview", c.Options.AddPanelView, "Add an embeded panel view. On kindle you may not need this option as it is handled by the kindle.")
c.AddIntParam(&c.Options.LimitMb, "limitmb", c.Options.LimitMb, "Limit size of the ePub: Default nolimit (0), Minimum 20")
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("Other")
c.AddBoolParam(&c.Options.Version, "version", false, "Show current and available version")
c.AddBoolParam(&c.Options.Help, "help", false, "Show this help message")
}
func (c *Converter) Usage(isString bool, f *flag.Flag) string {
var b strings.Builder
fmt.Fprintf(&b, " -%s", f.Name) // Two spaces before -; see next two comments.
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 {
fmt.Fprintf(&b, " (default %q)", f.DefValue)
} else {
fmt.Fprintf(&b, " (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()
}
// 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
}
func (c *Converter) Parse() {
c.Cmd.Parse(os.Args[1:])
if c.Options.Help {
c.Cmd.Usage()
os.Exit(0)
}
if c.Options.Auto {
c.Options.AutoRotate = true
c.Options.AutoSplitDoublePage = true
}
}
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 = fmt.Sprintf("%s.epub", inputBase)
} else {
ext := filepath.Ext(inputBase)
defaultOutput = fmt.Sprintf("%s.epub", inputBase[0:len(inputBase)-len(ext)])
}
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.Brightness < -100 || c.Options.Brightness > 100 {
return errors.New("brightness should be between -100 and 100")
}
// Contrast
if c.Options.Contrast < -100 || c.Options.Contrast > 100 {
return errors.New("contrast should be between -100 and 100")
}
return nil
}
func (c Converter) Fatal(err error) {
c.Cmd.Usage()
fmt.Fprintf(os.Stderr, "\nError: %s\n", err)
os.Exit(1)
}

View File

@ -1,176 +0,0 @@
package options
import (
"fmt"
"os"
"path/filepath"
"github.com/celogeek/go-comic-converter/v2/internal/converter/profiles"
"gopkg.in/yaml.v3"
)
type Options struct {
// Output
Input string `yaml:"-"`
Output string `yaml:"-"`
Author string `yaml:"-"`
Title string `yaml:"-"`
Auto bool `yaml:"-"`
Workers int `yaml:"-"`
Dry bool `yaml:"-"`
// Config
Profile string `yaml:"profile"`
Quality int `yaml:"quality"`
Crop bool `yaml:"crop"`
Brightness int `yaml:"brightness"`
Contrast int `yaml:"contrast"`
AutoRotate bool `yaml:"auto_rotate"`
AutoSplitDoublePage bool `yaml:"auto_split_double_page"`
NoBlankPage bool `yaml:"no_blank_page"`
Manga bool `yaml:"manga"`
HasCover bool `yaml:"has_cover"`
AddPanelView bool `yaml:"add_panel_view"`
LimitMb int `yaml:"limit_mb"`
// Default Config
Show bool `yaml:"-"`
Save bool `yaml:"-"`
Reset bool `yaml:"-"`
// Other
Version bool `yaml:"-"`
Help bool `yaml:"-"`
// Internal
profiles profiles.Profiles
}
func New() *Options {
return &Options{
Profile: "",
Quality: 85,
Crop: true,
Brightness: 0,
Contrast: 0,
AutoRotate: false,
AutoSplitDoublePage: false,
NoBlankPage: false,
Manga: false,
HasCover: true,
AddPanelView: false,
LimitMb: 0,
profiles: profiles.New(),
}
}
func (o *Options) Header() string {
return `Go Comic Converter
Options:`
}
func (o *Options) String() string {
return fmt.Sprintf(`%s
Input : %s
Output : %s
Author : %s
Title : %s
Workers : %d%s
`,
o.Header(),
o.Input,
o.Output,
o.Author,
o.Title,
o.Workers,
o.ShowDefault(),
)
}
func (o *Options) FileName() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".go-comic-converter.yaml")
}
func (o *Options) LoadDefault() error {
f, err := os.Open(o.FileName())
if err != nil {
return nil
}
defer f.Close()
err = yaml.NewDecoder(f).Decode(o)
if err != nil && err.Error() != "EOF" {
return err
}
return nil
}
func (o *Options) ShowDefault() string {
var profileDesc string
profile := o.GetProfile()
if profile != nil {
profileDesc = fmt.Sprintf(
"%s - %s - %dx%d - %d levels of gray",
o.Profile,
profile.Description,
profile.Width,
profile.Height,
len(profile.Palette),
)
}
limitmb := "nolimit"
if o.LimitMb > 0 {
limitmb = fmt.Sprintf("%d Mb", o.LimitMb)
}
return fmt.Sprintf(`
Profile : %s
Quality : %d
Crop : %v
Brightness : %d
Contrast : %d
AutoRotate : %v
AutoSplitDoublePage: %v
NoBlankPage : %v
Manga : %v
HasCover : %v
AddPanelView : %v
LimitMb : %s`,
profileDesc,
o.Quality,
o.Crop,
o.Brightness,
o.Contrast,
o.AutoRotate,
o.AutoSplitDoublePage,
o.NoBlankPage,
o.Manga,
o.HasCover,
o.AddPanelView,
limitmb,
)
}
func (o *Options) ResetDefault() error {
New().SaveDefault()
return o.LoadDefault()
}
func (o *Options) SaveDefault() error {
f, err := os.Create(o.FileName())
if err != nil {
return err
}
defer f.Close()
return yaml.NewEncoder(f).Encode(o)
}
func (o *Options) GetProfile() *profiles.Profile {
return o.profiles.Get(o.Profile)
}
func (o *Options) AvailableProfiles() string {
return o.profiles.String()
}

View File

@ -1,22 +0,0 @@
package converter
type Order interface {
Value() string
}
type OrderSection struct {
value string
}
func (s OrderSection) Value() string {
return s.value
}
type OrderName struct {
value string
isString bool
}
func (s OrderName) Value() string {
return s.value
}

View File

@ -1,72 +0,0 @@
package profiles
import (
"fmt"
"image/color"
"strings"
"github.com/celogeek/go-comic-converter/v2/internal/epub"
)
type Profile struct {
Code string
Description string
Width int
Height int
Palette color.Palette
}
type Profiles []Profile
func New() Profiles {
return []Profile{
{"K1", "Kindle 1", 600, 670, epub.PALETTE_4},
{"K11", "Kindle 11", 1072, 1448, epub.PALETTE_16},
{"K2", "Kindle 2", 600, 670, epub.PALETTE_15},
{"K34", "Kindle Keyboard/Touch", 600, 800, epub.PALETTE_16},
{"K578", "Kindle", 600, 800, epub.PALETTE_16},
{"KDX", "Kindle DX/DXG", 824, 1000, epub.PALETTE_16},
{"KPW", "Kindle Paperwhite 1/2", 758, 1024, epub.PALETTE_16},
{"KV", "Kindle Paperwhite 3/4/Voyage/Oasis", 1072, 1448, epub.PALETTE_16},
{"KPW5", "Kindle Paperwhite 5/Signature Edition", 1236, 1648, epub.PALETTE_16},
{"KO", "Kindle Oasis 2/3", 1264, 1680, epub.PALETTE_16},
{"KS", "Kindle Scribe", 1860, 2480, epub.PALETTE_16},
// Kobo
{"KoMT", "Kobo Mini/Touch", 600, 800, epub.PALETTE_16},
{"KoG", "Kobo Glo", 768, 1024, epub.PALETTE_16},
{"KoGHD", "Kobo Glo HD", 1072, 1448, epub.PALETTE_16},
{"KoA", "Kobo Aura", 758, 1024, epub.PALETTE_16},
{"KoAHD", "Kobo Aura HD", 1080, 1440, epub.PALETTE_16},
{"KoAH2O", "Kobo Aura H2O", 1080, 1430, epub.PALETTE_16},
{"KoAO", "Kobo Aura ONE", 1404, 1872, epub.PALETTE_16},
{"KoN", "Kobo Nia", 758, 1024, epub.PALETTE_16},
{"KoC", "Kobo Clara HD/Kobo Clara 2E", 1072, 1448, epub.PALETTE_16},
{"KoL", "Kobo Libra H2O/Kobo Libra 2", 1264, 1680, epub.PALETTE_16},
{"KoF", "Kobo Forma", 1440, 1920, epub.PALETTE_16},
{"KoS", "Kobo Sage", 1440, 1920, epub.PALETTE_16},
{"KoE", "Kobo Elipsa", 1404, 1872, epub.PALETTE_16},
}
}
func (p Profiles) String() string {
s := make([]string, 0)
for _, v := range p {
s = append(s, fmt.Sprintf(
" - %-7s ( %9s ) - %2d levels of gray - %s",
v.Code,
fmt.Sprintf("%dx%d", v.Width, v.Height),
len(v.Palette),
v.Description,
))
}
return strings.Join(s, "\n")
}
func (p Profiles) Get(name string) *Profile {
for _, profile := range p {
if profile.Code == name {
return &profile
}
}
return nil
}

View File

@ -1,249 +0,0 @@
package epub
import (
"fmt"
"image/color"
"path/filepath"
"regexp"
"strings"
"text/template"
"time"
"github.com/gofrs/uuid"
)
type ImageOptions struct {
Crop bool
ViewWidth int
ViewHeight int
Quality int
Algo string
Palette color.Palette
Brightness int
Contrast int
AutoRotate bool
AutoSplitDoublePage bool
NoBlankPage bool
Manga bool
HasCover bool
AddPanelView bool
Workers int
}
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)
}
stripBlank := regexp.MustCompile("\n+")
return stripBlank.ReplaceAllString(result.String(), "\n")
}
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]
if e.HasCover {
images = images[1:]
}
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
imgIsOnRightSide := false
for _, img := range images {
imgSize := img.Data.CompressedSize() + xhtmlSize
if maxSize > 0 && len(currentImages) > 0 && currentSize+imgSize > maxSize {
parts = append(parts, &epubPart{
Cover: cover,
Images: currentImages,
})
part += 1
imgIsOnRightSide = false
currentSize = baseSize
currentImages = make([]*Image, 0)
}
currentSize += imgSize
img.NeedSpace = img.Part == 1 && imgIsOnRightSide
currentImages = append(currentImages, img)
imgIsOnRightSide = !imgIsOnRightSide
}
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()
title := e.Title
if totalParts > 1 {
title = fmt.Sprintf("%s [%d/%d]", title, i+1, totalParts)
}
content := []zipContent{
{"META-INF/container.xml", containerTmpl},
{"OEBPS/content.opf", e.render(contentTmpl, map[string]any{
"Info": e,
"Cover": part.Cover,
"Images": part.Images,
"Title": title,
"Part": i + 1,
"Total": totalParts,
})},
{"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 e.AddPanelView {
content = append(content, zipContent{"OEBPS/Text/panelview.css", panelViewTmpl})
}
if err = wz.WriteMagic(); err != nil {
return err
}
for _, content := range content {
if err := wz.WriteFile(content.Name, content.Content); err != nil {
return err
}
}
// Cover exist or part > 1
// If no cover, part 2 and more will include the image as a cover
if e.HasCover || i > 0 {
wz.WriteImage(part.Cover.Data)
}
for _, img := range part.Images {
var content string
if e.AddPanelView {
content = e.render(textTmpl, map[string]any{
"Image": img,
"Manga": e.Manga,
})
} else {
content = e.render(textNoPanelTmpl, map[string]any{
"Image": img,
})
}
if err := wz.WriteFile(fmt.Sprintf("OEBPS/Text/%d_p%d.xhtml", img.Id, img.Part), content); err != nil {
return err
}
if img.NeedSpace {
if err := wz.WriteFile(
fmt.Sprintf("OEBPS/Text/%d_sp.xhtml", img.Id),
e.render(blankTmpl, map[string]any{
"Info": e,
"Image": img,
}),
); err != nil {
return err
}
}
if err := wz.WriteImage(img.Data); err != nil {
return err
}
}
bar.Add(1)
}
bar.Close()
return nil
}

View File

@ -1,46 +0,0 @@
package filters
import (
"image"
"image/draw"
"github.com/disintegration/gift"
)
func AutoRotate(viewWidth, viewHeight int) gift.Filter {
return &autoRotateFilter{
viewWidth, viewHeight,
}
}
type autoRotateFilter struct {
viewWidth, viewHeight int
}
func (p *autoRotateFilter) needRotate(srcBounds image.Rectangle) bool {
width, height := srcBounds.Dx(), srcBounds.Dy()
if width <= height {
return false
}
if width <= p.viewWidth && height <= p.viewHeight {
return false
}
return true
}
func (p *autoRotateFilter) Bounds(srcBounds image.Rectangle) (dstBounds image.Rectangle) {
if p.needRotate(srcBounds) {
dstBounds = gift.Rotate90().Bounds(srcBounds)
} else {
dstBounds = srcBounds
}
return
}
func (p *autoRotateFilter) Draw(dst draw.Image, src image.Image, options *gift.Options) {
if p.needRotate(src.Bounds()) {
gift.Rotate90().Draw(dst, src, options)
} else {
draw.Draw(dst, dst.Bounds(), src, src.Bounds().Min, draw.Src)
}
}

View File

@ -1,47 +0,0 @@
package filters
import (
"image"
"image/draw"
"github.com/disintegration/gift"
)
func Resize(viewWidth, viewHeight int, resampling gift.Resampling) gift.Filter {
return &resizeFilter{
viewWidth, viewHeight, resampling,
}
}
type resizeFilter struct {
viewWidth, viewHeight int
resampling gift.Resampling
}
func (p *resizeFilter) Bounds(srcBounds image.Rectangle) image.Rectangle {
w, h := p.viewWidth, p.viewHeight
srcw, srch := srcBounds.Dx(), srcBounds.Dy()
if w <= 0 || h <= 0 || srcw <= 0 || srch <= 0 {
return image.Rect(0, 0, 0, 0)
}
wratio := float64(srcw) / float64(w)
hratio := float64(srch) / float64(h)
var dstw, dsth int
if wratio > hratio {
dstw = w
dsth = int(float64(srch)/wratio + 0.5)
} else {
dsth = h
dstw = int(float64(srcw)/hratio + 0.5)
}
return image.Rect(0, 0, dstw, dsth)
}
func (p *resizeFilter) Draw(dst draw.Image, src image.Image, options *gift.Options) {
b := p.Bounds(src.Bounds())
gift.Resize(b.Dx(), b.Dy(), p.resampling).Draw(dst, src, options)
}

View File

@ -1,53 +0,0 @@
package epub
import (
"archive/zip"
"bytes"
"compress/flate"
"fmt"
"hash/crc32"
"image"
"image/jpeg"
"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(id int, part int, img image.Image, quality int) *ImageData {
name := fmt.Sprintf("OEBPS/Images/%d_p%d.jpg", id, part)
data := bytes.NewBuffer([]byte{})
if err := jpeg.Encode(data, img, &jpeg.Options{Quality: quality}); err != nil {
panic(err)
}
cdata := bytes.NewBuffer([]byte{})
wcdata, err := flate.NewWriter(cdata, flate.BestCompression)
if err != nil {
panic(err)
}
wcdata.Write(data.Bytes())
wcdata.Close()
if err != nil {
panic(err)
}
t := time.Now()
return &ImageData{
&zip.FileHeader{
Name: name,
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(),
}
}

View File

@ -1,54 +0,0 @@
package epub
import (
"github.com/celogeek/go-comic-converter/v2/internal/epub/filters"
"github.com/disintegration/gift"
)
func NewGift(options *ImageOptions) *gift.GIFT {
g := gift.New()
g.SetParallelization(false)
if options.AutoRotate {
g.Add(filters.AutoRotate(options.ViewWidth, options.ViewHeight))
}
if options.Contrast != 0 {
g.Add(gift.Contrast(float32(options.Contrast)))
}
if options.Brightness != 0 {
g.Add(gift.Brightness(float32(options.Brightness)))
}
g.Add(
filters.Resize(options.ViewWidth, options.ViewHeight, gift.LanczosResampling),
filters.Pixel(),
)
return g
}
func NewGiftSplitDoublePage(options *ImageOptions) []*gift.GIFT {
gifts := make([]*gift.GIFT, 2)
rightFirst := options.Manga
gifts[0] = gift.New(
filters.CropSplitDoublePage(rightFirst),
)
gifts[1] = gift.New(
filters.CropSplitDoublePage(!rightFirst),
)
for _, g := range gifts {
if options.Contrast != 0 {
g.Add(gift.Contrast(float32(options.Contrast)))
}
if options.Brightness != 0 {
g.Add(gift.Brightness(float32(options.Brightness)))
}
g.Add(
gift.ResizeToFit(options.ViewWidth, options.ViewHeight, gift.LanczosResampling),
)
}
return gifts
}

View File

@ -1,418 +0,0 @@
package epub
import (
"archive/zip"
"bytes"
"fmt"
"image"
"image/color"
_ "image/jpeg"
_ "image/png"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"github.com/disintegration/gift"
"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
Part int
Data *ImageData
Width int
Height int
IsCover bool
NeedSpace bool
}
type imageTask struct {
Id int
Reader io.ReadCloser
Filename string
}
func colorIsBlank(c color.Color) bool {
g := color.GrayModel.Convert(c).(color.Gray)
return g.Y >= 0xf0
}
func findMarging(img image.Image) image.Rectangle {
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 !colorIsBlank(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 !colorIsBlank(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 !colorIsBlank(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 !colorIsBlank(img.At(x, y)) {
break BOTTOM
}
}
imgArea.Max.Y--
}
return imgArea
}
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
bar := NewBar(imageCount, "Processing", 1, 2)
wg := &sync.WaitGroup{}
for i := 0; i < options.Workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for img := range imageInput {
// Decode image
src, _, err := image.Decode(img.Reader)
if err != nil {
bar.Clear()
fmt.Fprintf(os.Stderr, "error processing image %s: %s\n", img.Filename, err)
os.Exit(1)
}
if options.Crop {
g := gift.New(gift.Crop(findMarging(src)))
newSrc := image.NewNRGBA(g.Bounds(src.Bounds()))
g.Draw(newSrc, src)
src = newSrc
}
g := NewGift(options)
// Convert image
dst := image.NewPaletted(g.Bounds(src.Bounds()), options.Palette)
g.Draw(dst, src)
imageOutput <- &Image{
img.Id,
0,
newImageData(img.Id, 0, dst, options.Quality),
dst.Bounds().Dx(),
dst.Bounds().Dy(),
img.Id == 0,
false,
}
// Auto split double page
// Except for cover
// Only if the src image have width > height and is bigger than the view
if (!options.HasCover || img.Id > 0) &&
options.AutoSplitDoublePage &&
src.Bounds().Dx() > src.Bounds().Dy() &&
src.Bounds().Dx() > options.ViewHeight &&
src.Bounds().Dy() > options.ViewWidth {
gifts := NewGiftSplitDoublePage(options)
for i, g := range gifts {
part := i + 1
dst := image.NewPaletted(g.Bounds(src.Bounds()), options.Palette)
g.Draw(dst, src)
imageOutput <- &Image{
img.Id,
part,
newImageData(img.Id, part, dst, options.Quality),
dst.Bounds().Dx(),
dst.Bounds().Dy(),
false,
false, // NeedSpace reajust during parts computation
}
}
}
}
}()
}
go func() {
wg.Wait()
close(imageOutput)
}()
for image := range imageOutput {
if !(options.NoBlankPage && image.Width == 1 && image.Height == 1) {
images = append(images, image)
}
if image.Part == 0 {
bar.Add(1)
}
}
bar.Close()
if len(images) == 0 {
return nil, fmt.Errorf("image not found")
}
sort.Slice(images, func(i, j int) bool {
if images[i].Id < images[j].Id {
return true
} else if images[i].Id == images[j].Id && images[i].Part < images[j].Part {
return true
}
return false
})
return images, nil
}
func isSupportedImage(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".jpg", ".jpeg", ".png":
{
return true
}
}
return false
}
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() && isSupportedImage(path) {
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,
Filename: img,
}
}
}()
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() && isSupportedImage(f.Name) {
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,
Filename: img.Name,
}
}
}()
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 && isSupportedImage(f.Name) {
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),
Filename: f.Name,
}
}
}
}()
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),
Filename: fmt.Sprintf("page %d", i+1),
}
}
}()
return nbPages, output, nil
}

View File

@ -1,47 +0,0 @@
package epub
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},
}

View File

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

View File

@ -1,33 +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/panelview.css.tmpl"
var panelViewTmpl string
//go:embed "templates/part.xhtml.tmpl"
var partTmpl string
//go:embed "templates/text.xhtml.tmpl"
var textTmpl string
//go:embed "templates/textnopanel.xhtml.tmpl"
var textNoPanelTmpl string
//go:embed "templates/blank.xhtml.tmpl"
var blankTmpl string

View File

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

View File

@ -1,57 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<package version="3.0" unique-identifier="BookID" xmlns="http://www.idpf.org/2007/opf">
{{ $info := .Info }}
<metadata xmlns:opf="http://www.idpf.org/2007/opf" xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>{{ .Title }}</dc:title>
<dc:language>en-US</dc:language>
<dc:identifier id="BookID">urn:uuid:{{ $info.UID }}</dc:identifier>
<dc:contributor id="contributor">{{ $info.Publisher }}</dc:contributor>
<dc:publisher>{{ $info.Publisher }}</dc:publisher>
<dc:date>{{ $info.UpdatedAt }}</dc:date>
<dc:creator>{{ $info.Author }}</dc:creator>
<meta property="dcterms:modified">{{ $info.UpdatedAt }}</meta>
<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-{{ if $info.Manga }}rl{{ else }}lr{{ end }}"/>
<meta property="rendition:orientation">portrait</meta>
<meta name="orientation-lock" content="portrait"/>
{{ if eq $info.AddPanelView true }}
<meta name="region-mag" content="true"/>
{{ end }}
{{ if gt .Total 1 }}
<meta name="calibre:series" content="{{ $info.Title }}"/>
<meta name="calibre:series_index" content="{{ .Part }}"/>
{{ end }}
</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="style_css" href="Text/style.css" media-type="text/css"/>
{{ if eq $info.AddPanelView true }}
<item id="panelview_css" href="Text/panelview.css" media-type="text/css"/>
{{ end }}
<item id="cover" href="Images/{{ .Cover.Id }}_p{{ .Cover.Part }}.jpg" media-type="image/jpeg" properties="cover-image"/>
{{ range .Images }}
{{ if eq .IsCover false }}
<item id="img_{{ .Id }}_p{{ .Part}}" href="Images/{{ .Id }}_p{{ .Part}}.jpg" media-type="image/jpeg"/>
{{ end }}
{{ end }}
<item id="page_part" href="Text/part.xhtml" media-type="application/xhtml+xml"/>
{{ range .Images }}
<item id="page_{{ .Id }}_p{{ .Part}}" href="Text/{{ .Id }}_p{{ .Part}}.xhtml" media-type="application/xhtml+xml"/>
{{ if eq .NeedSpace true }}
<item id="page_{{ .Id }}_sp" href="Text/{{ .Id }}_sp.xhtml" media-type="application/xhtml+xml"/>
{{ end }}
{{ end }}
</manifest>
<spine toc="ncx" page-progression-direction="{{ if $info.Manga }}rtl{{ else }}ltr{{ end }}">
<itemref idref="page_part" linear="yes"/>
{{ range .Images }}
{{ if eq .NeedSpace true }}
<itemref idref="page_{{ .Id }}_sp" linear="yes"/>
{{ end }}
<itemref idref="page_{{ .Id }}_p{{ .Part }}" linear="yes"/>
{{ end }}
</spine>
</package>

View File

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

View File

@ -1,103 +0,0 @@
a.app-amzn-magnify {
display: inline-block;
width: 100%;
height: 100%;
}
#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;
}
div#PV-TL-P img {
position: absolute;
left: 0;
top: 0;
}
div#PV-TR-P img {
position: absolute;
right: 0;
top: 0;
}
div#PV-BL-P img {
position: absolute;
left: 0;
bottom: 0;
}
div#PV-BR-P img {
position: absolute;
right: 0;
bottom: 0;
}

View File

@ -1,77 +0,0 @@
@charset "UTF-8";
html {
color: #000;
background: #FFF;
}
body {
font-size: 16px;
text-align: center;
width: 100%;
height: 100%;
}
body,
div,
dl,
dt,
dd,
ul,
ol,
li,
h1,
h2,
h3,
h4,
h5,
h6,
th,
td {
margin: 0;
padding: 0
}
table {
border-collapse: collapse;
border-spacing: 0;
}
fieldset,
img {
border: 0;
}
caption,
th,
var {
font-style: normal;
font-weight: normal;
}
li {
list-style: none;
}
caption,
th {
text-align: left;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: 150%;
font-weight: normal;
}
sup {
vertical-align: text-top;
}
sub {
vertical-align: text-bottom;
}

View File

@ -1,41 +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 {{ .Image.Id }}_p{{ .Image.Part}}</title>
<link href="style.css" type="text/css" rel="stylesheet"/>
<link href="panelview.css" type="text/css" rel="stylesheet"/>
<meta name="viewport" content="width={{ .Image.Width }}, height={{ .Image.Height }}"/>
</head>
<body>
<div>
<img style="width:{{ .Image.Width }}px; height:{{ .Image.Height }}px" src="../Images/{{ .Image.Id }}_p{{ .Image.Part}}.jpg"/>
</div>
<div id="PV">
<div id="PV-TL">
<a class="app-amzn-magnify" data-app-amzn-magnify='{"targetId":"PV-TL-P", "ordinal":{{ if .Manga }}2{{ else }}1{{ end }}}'></a>
</div>
<div id="PV-TR">
<a class="app-amzn-magnify" data-app-amzn-magnify='{"targetId":"PV-TR-P", "ordinal":{{ if .Manga }}1{{ else }}2{{ end }}}'></a>
</div>
<div id="PV-BL">
<a class="app-amzn-magnify" data-app-amzn-magnify='{"targetId":"PV-BL-P", "ordinal":{{ if .Manga }}4{{ else }}3{{ end }}}'></a>
</div>
<div id="PV-BR">
<a class="app-amzn-magnify" data-app-amzn-magnify='{"targetId":"PV-BR-P", "ordinal":{{ if .Manga }}3{{ else }}4{{ end }}}'></a>
</div>
</div>
<div class="PV-P" id="PV-TL-P">
<img src="../Images/{{ .Image.Id }}_p{{ .Image.Part}}.jpg" width="{{ zoom .Image.Width 1.5 }}" height="{{ zoom .Image.Height 1.5 }}"/>
</div>
<div class="PV-P" id="PV-TR-P">
<img src="../Images/{{ .Image.Id }}_p{{ .Image.Part}}.jpg" width="{{ zoom .Image.Width 1.5 }}" height="{{ zoom .Image.Height 1.5 }}"/>
</div>
<div class="PV-P" id="PV-BL-P">
<img src="../Images/{{ .Image.Id }}_p{{ .Image.Part}}.jpg" width="{{ zoom .Image.Width 1.5 }}" height="{{ zoom .Image.Height 1.5 }}"/>
</div>
<div class="PV-P" id="PV-BR-P">
<img src="../Images/{{ .Image.Id }}_p{{ .Image.Part}}.jpg" width="{{ zoom .Image.Width 1.5 }}" height="{{ zoom .Image.Height 1.5 }}"/>
</div>
</body>
</html>

View File

@ -1,14 +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 {{ .Image.Id }}_p{{ .Image.Part}}</title>
<link href="style.css" type="text/css" rel="stylesheet"/>
<meta name="viewport" content="width={{ .Image.Width }}, height={{ .Image.Height }}"/>
</head>
<body>
<div>
<img style="width:{{ .Image.Width }}px; height:{{ .Image.Height }}px" src="../Images/{{ .Image.Id }}_p{{ .Image.Part}}.jpg"/>
</div>
</body>
</html>

View File

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

View File

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

View 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,
)
}
}

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

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

View 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")
}

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

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

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

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

View File

@ -1,4 +1,4 @@
package filters package epubimagefilters
import ( import (
"image" "image"
@ -7,15 +7,18 @@ import (
"github.com/disintegration/gift" "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 { func CropSplitDoublePage(right bool) gift.Filter {
return &cropSplitDoublePage{right} return cropSplitDoublePage{right}
} }
type cropSplitDoublePage struct { type cropSplitDoublePage struct {
right bool right bool
} }
func (p *cropSplitDoublePage) Bounds(srcBounds image.Rectangle) (dstBounds image.Rectangle) { func (p cropSplitDoublePage) Bounds(srcBounds image.Rectangle) (dstBounds image.Rectangle) {
if p.right { if p.right {
dstBounds = image.Rect( dstBounds = image.Rect(
srcBounds.Max.X/2, srcBounds.Min.Y, srcBounds.Max.X/2, srcBounds.Min.Y,
@ -30,6 +33,6 @@ func (p *cropSplitDoublePage) Bounds(srcBounds image.Rectangle) (dstBounds image
return return
} }
func (p *cropSplitDoublePage) Draw(dst draw.Image, src image.Image, options *gift.Options) { func (p cropSplitDoublePage) Draw(dst draw.Image, src image.Image, options *gift.Options) {
gift.Crop(dst.Bounds()).Draw(dst, src, options) gift.Crop(dst.Bounds()).Draw(dst, src, options)
} }

View File

@ -1,4 +1,4 @@
package filters package epubimagefilters
import ( import (
"image" "image"
@ -8,14 +8,17 @@ import (
"github.com/disintegration/gift" "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 { func Pixel() gift.Filter {
return &pixel{} return pixel{}
} }
type pixel struct { type pixel struct {
} }
func (p *pixel) Bounds(srcBounds image.Rectangle) (dstBounds image.Rectangle) { func (p pixel) Bounds(srcBounds image.Rectangle) (dstBounds image.Rectangle) {
if srcBounds.Dx() == 0 || srcBounds.Dy() == 0 { if srcBounds.Dx() == 0 || srcBounds.Dy() == 0 {
dstBounds = image.Rect(0, 0, 1, 1) dstBounds = image.Rect(0, 0, 1, 1)
} else { } else {
@ -24,9 +27,10 @@ func (p *pixel) Bounds(srcBounds image.Rectangle) (dstBounds image.Rectangle) {
return return
} }
func (p *pixel) Draw(dst draw.Image, src image.Image, options *gift.Options) { func (p pixel) Draw(dst draw.Image, src image.Image, _ *gift.Options) {
if dst.Bounds().Dx() == 1 && dst.Bounds().Dy() == 1 { if dst.Bounds().Dx() == 1 && dst.Bounds().Dy() == 1 {
dst.Set(0, 0, color.White) dst.Set(0, 0, color.White)
return
} }
draw.Draw(dst, dst.Bounds(), src, src.Bounds().Min, draw.Src) draw.Draw(dst, dst.Bounds(), src, src.Bounds().Min, draw.Src)
} }

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

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

View 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,
)
}

View 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: "]",
}),
)
}

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

View File

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

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

View 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

View File

@ -2,11 +2,11 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head> <head>
<title>Page {{ .Image.Id }} Space</title> <meta charset="utf-8" />
<title>{{ .Title }}</title>
<link href="style.css" type="text/css" rel="stylesheet"/> <link href="style.css" type="text/css" rel="stylesheet"/>
<meta name="viewport" content="width={{ .Info.ViewWidth }}, height={{ .Info.ViewHeight }}"/> <meta name="viewport" content="{{ .ViewPort }}"/>
</head> </head>
<body> <body>
<h1>{{ if .Info.Manga }}&#8592;{{ else }}&#8594;{{ end }}</h1>
</body> </body>
</html> </html>

View File

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

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

View 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()}, ""},
}
}

View 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;
}

View File

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

View File

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

View File

@ -2,14 +2,12 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head> <head>
<title>Part {{ .Part }}</title> <meta charset="utf-8" />
<title>{{ .Title }}</title>
<link href="style.css" type="text/css" rel="stylesheet"/> <link href="style.css" type="text/css" rel="stylesheet"/>
<meta name="viewport" content="width={{ .Info.ViewWidth }}, height={{ .Info.ViewHeight }}"/> <meta name="viewport" content="{{ .ViewPort }}"/>
</head> </head>
<body> <body>
<h1>{{ .Info.Title }}</h1> <img src="../{{ .ImagePath }}" alt="{{ .Title }}" style="{{ .ImageStyle }}"/>
{{ if gt .Total 1 }}
<h1>Part {{ .Part }} / {{ .Total }}</h1>
{{ end }}
</body> </body>
</html> </html>

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

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

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

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

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

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

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

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

View 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"
}

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

178
main.go
View File

@ -1,13 +1,22 @@
/*
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 (
"fmt" "encoding/json"
"os" "os"
"runtime/debug" "runtime/debug"
"github.com/celogeek/go-comic-converter/v2/internal/converter"
"github.com/celogeek/go-comic-converter/v2/internal/epub"
"github.com/tcnksm/go-latest" "github.com/tcnksm/go-latest"
"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"
) )
func main() { func main() {
@ -18,25 +27,41 @@ func main() {
cmd.InitParse() cmd.InitParse()
cmd.Parse() cmd.Parse()
if cmd.Options.Version { switch {
bi, ok := debug.ReadBuildInfo() case cmd.Options.Version:
if !ok { version()
fmt.Fprintln(os.Stderr, "failed to fetch current version") case cmd.Options.Save:
os.Exit(1) save(cmd)
} case cmd.Options.Show:
show(cmd)
case cmd.Options.Reset:
reset(cmd)
default:
generate(cmd)
}
githubTag := &latest.GithubTag{ }
Owner: "celogeek",
Repository: "go-comic-converter",
}
v, err := githubTag.Fetch()
if err != nil || len(v.Versions) < 1 {
fmt.Fprintln(os.Stderr, "failed to fetch the latest version")
os.Exit(1)
}
latest_version := v.Versions[0]
fmt.Printf(`go-comic-converter func version() {
bi, ok := debug.ReadBuildInfo()
if !ok {
utils.Fatalln("failed to fetch current version")
}
githubTag := &latest.GithubTag{
Owner: "celogeek",
Repository: "go-comic-converter",
}
v, err := githubTag.Fetch()
if err != nil {
utils.Fatalln("failed to fetch the latest version")
}
if len(v.Versions) < 1 {
utils.Fatalln("no versions found")
}
latestVersion := v.Versions[0]
utils.Printf(`go-comic-converter
Path : %s Path : %s
Sum : %s Sum : %s
Version : %s Version : %s
@ -45,82 +70,65 @@ func main() {
To install the latest version: To install the latest version:
$ go install github.com/celogeek/go-comic-converter/v%d@%s $ go install github.com/celogeek/go-comic-converter/v%d@%s
`, `,
bi.Main.Path, bi.Main.Path,
bi.Main.Sum, bi.Main.Sum,
bi.Main.Version, bi.Main.Version,
latest_version.Original(), latestVersion.Original(),
latest_version.Segments()[0], latestVersion.Segments()[0],
latest_version.Original(), latestVersion.Original(),
) )
return }
}
if cmd.Options.Save { func save(cmd *converter.Converter) {
cmd.Options.SaveDefault() if err := cmd.Options.SaveConfig(); err != nil {
fmt.Fprintf( cmd.Fatal(err)
os.Stderr,
"%s%s\n\nSaving to %s\n",
cmd.Options.Header(),
cmd.Options.ShowDefault(),
cmd.Options.FileName(),
)
return
} }
utils.Printf(
"%s%s\n\nSaving to %s\n",
cmd.Options.Header(),
cmd.Options.ShowConfig(),
cmd.Options.FileName(),
)
}
if cmd.Options.Show { func show(cmd *converter.Converter) {
fmt.Fprintln(os.Stderr, cmd.Options.Header(), cmd.Options.ShowDefault()) utils.Println(cmd.Options.Header(), cmd.Options.ShowConfig())
return }
}
func reset(cmd *converter.Converter) {
if cmd.Options.Reset { if err := cmd.Options.ResetConfig(); err != nil {
cmd.Options.ResetDefault() cmd.Fatal(err)
fmt.Fprintf(
os.Stderr,
"%s%s\n\nReset default to %s\n",
cmd.Options.Header(),
cmd.Options.ShowDefault(),
cmd.Options.FileName(),
)
return
} }
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 { if err := cmd.Validate(); err != nil {
cmd.Fatal(err) cmd.Fatal(err)
} }
fmt.Fprintln(os.Stderr, cmd.Options) if profile := cmd.Options.GetProfile(); profile != nil {
cmd.Options.Image.View.Width = profile.Width
if cmd.Options.Dry { cmd.Options.Image.View.Height = profile.Height
return
} }
profile := cmd.Options.GetProfile() if cmd.Options.Json {
if err := epub.NewEpub(&epub.EpubOptions{ _ = json.NewEncoder(os.Stdout).Encode(map[string]any{
Input: cmd.Options.Input, "type": "options", "data": cmd.Options,
Output: cmd.Options.Output, })
LimitMb: cmd.Options.LimitMb, } else {
Title: cmd.Options.Title, utils.Println(cmd.Options)
Author: cmd.Options.Author,
ImageOptions: &epub.ImageOptions{
ViewWidth: profile.Width,
ViewHeight: profile.Height,
Quality: cmd.Options.Quality,
Crop: cmd.Options.Crop,
Palette: profile.Palette,
Brightness: cmd.Options.Brightness,
Contrast: cmd.Options.Contrast,
AutoRotate: cmd.Options.AutoRotate,
AutoSplitDoublePage: cmd.Options.AutoSplitDoublePage,
NoBlankPage: cmd.Options.NoBlankPage,
Manga: cmd.Options.Manga,
HasCover: cmd.Options.HasCover,
AddPanelView: cmd.Options.AddPanelView,
Workers: cmd.Options.Workers,
},
}).Write(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
} }
os.Exit(0) if err := epub.New(cmd.Options.EPUBOptions).Write(); err != nil {
utils.Fatalf("Error: %v\n", err)
}
if !cmd.Options.Dry {
cmd.Stats()
}
} }

509
pkg/epub/epub.go Normal file
View 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
View 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
View 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"`
}

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