1
0
mirror of https://github.com/ciromattia/kcc synced 2026-04-18 06:58:58 +00:00

Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Xu
aa9ee43278 specify you should clone a fork, not the main KCC repo 2025-05-26 11:29:38 -07:00
46 changed files with 1995 additions and 6361 deletions

View File

@@ -1,40 +1,13 @@
.git .git
.github .github
build build
dist dist
KindleComicConverter.egg-info KindleComicConverter.egg-info
.dockerignore .dockerignore
.gitignore .gitignore
.travis.yml .travis.yml
Dockerfile Dockerfile
venv venv
.venv
__pycache__/
*/__pycache__/
*.pyc
*.md *.md
*.txt LICENSE.txt
!requirements-docker.txt
MANIFEST.in MANIFEST.in
*.yml
*.spec
*.svg
*.jpg
*.json
gen_ui_files.bat
gen_ui_files.sh
gui/
icons/
kindlecomicconverter/KCC_gui.py
kindlecomicconverter/KCC_rc.py
kindlecomicconverter/KCC_ui_editor.py
kindlecomicconverter/KCC_ui.py

15
.github/FUNDING.yml vendored
View File

@@ -1,15 +0,0 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: eink_dude
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -38,11 +38,11 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v4 uses: github/codeql-action/init@v3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -56,7 +56,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v4 uses: github/codeql-action/autobuild@v3
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -69,6 +69,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4 uses: github/codeql-action/analyze@v3
with: with:
category: "/language:${{matrix.language}}" category: "/language:${{matrix.language}}"

View File

@@ -0,0 +1,34 @@
name: Docker base
on:
workflow_dispatch:
push:
tags: [ 'docker-base-*' ]
# Don't trigger if it's just a documentation update
paths-ignore:
- '**.md'
- '**.MD'
- '**.yml'
- 'docs/**'
- 'LICENSE'
- '.gitattributes'
- '.gitignore'
- '.dockerignore'
jobs:
build_and_push:
uses: sdr-enthusiasts/common-github-workflows/.github/workflows/build_and_push_image.yml@main
with:
docker_build_file: ./Dockerfile-base
platform_linux_arm32v7_enabled: true
platform_linux_arm64v8_enabled: true
platform_linux_amd64_enabled: true
push_enabled: true
build_nohealthcheck: false
ghcr_repo_owner: ${{ github.repository_owner }}
ghcr_repo: ${{ github.repository }}
build_latest: false
secrets:
ghcr_token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,10 +1,10 @@
name: Build and Publish Docker Image name: Docker
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
tags: # Publish semver tags as releases.
- 'v*.*.*' tags: [ 'v*.*.*' ]
# Don't trigger if it's just a documentation update # Don't trigger if it's just a documentation update
paths-ignore: paths-ignore:
@@ -15,53 +15,19 @@ on:
- 'LICENSE' - 'LICENSE'
- '.gitattributes' - '.gitattributes'
- '.gitignore' - '.gitignore'
- '.dockerignore'
jobs: jobs:
build_and_publish_base_image: build_and_push:
runs-on: ubuntu-latest uses: sdr-enthusiasts/common-github-workflows/.github/workflows/build_and_push_image.yml@main
steps: with:
- name: Checkout platform_linux_arm32v7_enabled: true
uses: actions/checkout@v6 platform_linux_arm64v8_enabled: true
platform_linux_amd64_enabled: true
- name: Login to GitHub Container Registry push_enabled: true
uses: docker/login-action@v3 build_nohealthcheck: false
with: ghcr_repo_owner: ${{ github.repository_owner }}
registry: ghcr.io ghcr_repo: ${{ github.repository }}
username: ${{ github.repository_owner }} secrets:
password: ${{ secrets.GITHUB_TOKEN }} ghcr_token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set Release Date
id: release_date
run: |
echo "release_date=$(date --rfc-3339=date)" >> $GITHUB_OUTPUT
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/kcc
# Always creates the "latest" tag
flavor: |
latest=true
tags: |
type=ref,event=tag
type=raw,value=${{ steps.release_date.outputs.release_date }}
- name: Build and push
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64,linux/arm/v7
context: .
push: true
tags: |
${{ steps.meta.outputs.tags }}
cache-from: |
type=registry,ref=ghcr.io/ciromattia/kcc:cache
type=registry,ref=ghcr.io/${{ github.repository_owner }}/kcc:cache
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/kcc:cache,mode=max

View File

@@ -25,9 +25,9 @@ jobs:
build: build:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v5
with: with:
python-version: 3.11 python-version: 3.11
cache: 'pip' cache: 'pip'
@@ -35,7 +35,7 @@ jobs:
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libpng-dev libjpeg-dev p7zip-full p7zip-rar python3-pip squashfs-tools libfuse2 libxcb-cursor0 sudo apt-get install -y libpng-dev libjpeg-dev p7zip-full p7zip-rar python3-pip squashfs-tools libfuse2 libxcb-cursor0
python -m pip install --upgrade pip certifi pyinstaller --no-binary pyinstaller python -m pip install --upgrade pip setuptools wheel certifi pyinstaller --no-binary pyinstaller
python -m pip install -r requirements.txt python -m pip install -r requirements.txt
- name: build binary - name: build binary
run: | run: |
@@ -59,7 +59,7 @@ jobs:
env: env:
UPDATE_INFO: gh-releases-zsync|ciromattia|kcc|latest|*x86_64.AppImage.zsync UPDATE_INFO: gh-releases-zsync|ciromattia|kcc|latest|*x86_64.AppImage.zsync
- name: upload artifact - name: upload artifact
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v4
with: with:
name: AppImage name: AppImage
path: './*.AppImage*' path: './*.AppImage*'
@@ -68,7 +68,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
with: with:
prerelease: true prerelease: true
generate_release_notes: false generate_release_notes: true
files: | files: |
LICENSE.txt LICENSE.txt
*.AppImage* *.AppImage*

View File

@@ -25,20 +25,18 @@ jobs:
build: build:
strategy: strategy:
matrix: matrix:
os: [ macos-15-intel, macos-14 ] os: [ macos-13, macos-14 ]
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
env:
MACOSX_DEPLOYMENT_TARGET: '14.0'
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v5
with: with:
python-version: 3.11 python-version: 3.11
cache: 'pip' cache: 'pip'
- name: Install python dependencies - name: Install python dependencies
run: | run: |
python -m pip install --upgrade pip pyinstaller certifi python -m pip install --upgrade pip setuptools wheel pyinstaller certifi
pip install -r requirements.txt pip install -r requirements.txt
- name: Install the Apple certificate and provisioning profile - name: Install the Apple certificate and provisioning profile
# TODO signing # TODO signing
@@ -71,7 +69,7 @@ jobs:
# apply provisioning profile # apply provisioning profile
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
- uses: actions/setup-node@v6 - uses: actions/setup-node@v4
with: with:
node-version: 16 node-version: 16
- run: npm install -g appdmg - run: npm install -g appdmg
@@ -80,7 +78,7 @@ jobs:
run: | run: |
python setup.py build_binary python setup.py build_binary
- name: upload build - name: upload build
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v4
with: with:
name: mac-os-build-${{ runner.arch }} name: mac-os-build-${{ runner.arch }}
path: dist/*.dmg path: dist/*.dmg
@@ -89,8 +87,9 @@ jobs:
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
with: with:
prerelease: true prerelease: true
generate_release_notes: false generate_release_notes: true
files: | files: |
LICENSE.txt
dist/*.dmg dist/*.dmg
- name: Clean up keychain and provisioning profile - name: Clean up keychain and provisioning profile
# TODO signing # TODO signing

View File

@@ -1,66 +0,0 @@
name: build KCC for osx legacy
on:
workflow_dispatch:
push:
tags:
- "v*.*.*"
# Don't trigger if it's just a documentation update
paths-ignore:
- '**.md'
- '**.MD'
- '**.yml'
- '**.sh'
- 'docs/**'
- 'Dockerfile'
- 'LICENSE'
- '.gitattributes'
- '.gitignore'
- '.dockerignore'
jobs:
build:
strategy:
matrix:
os: [ macos-15-intel ]
runs-on: ${{ matrix.os }}
env:
# We need the official Python, because the GA ones only support newer macOS versions
# The deployment target is picked up by the Python build tools automatically
PYTHON_VERSION: 3.11.9
MACOSX_DEPLOYMENT_TARGET: '10.14'
steps:
- uses: actions/checkout@v6
- name: Get Python
run: curl https://www.python.org/ftp/python/3.11.9/python-3.11.9-macos11.pkg -o "python.pkg"
- name: Install Python
run: |
sudo installer -pkg python.pkg -target /
- name: Install Python dependencies
run: |
python3 --version
pip3 install --upgrade pip pyinstaller certifi
pip3 install --upgrade -r requirements-osx-legacy.txt
./gen_ui_files.sh
- uses: actions/setup-node@v6
with:
node-version: 16
- run: npm install -g appdmg
- name: build binary
run: |
python3 setup.py build_binary
- name: upload build
uses: actions/upload-artifact@v6
with:
name: osx-build-${{ runner.arch }}
path: dist/*.dmg
- name: Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
prerelease: true
generate_release_notes: false
files: |
LICENSE.txt
dist/*.dmg

View File

@@ -0,0 +1,62 @@
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
name: build KCC for windows with docker
on:
workflow_dispatch:
push:
tags:
- "v*.*.*"
jobs:
build:
strategy:
matrix:
entry: [ kcc-c2e, kcc-c2p ]
include:
- entry: kcc-c2e
capital: KCC_c2e
- entry: kcc-c2p
capital: KCC_c2p
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Package Application
uses: JackMcKew/pyinstaller-action-windows@main
with:
path: .
spec: ./${{ matrix.entry }}.spec
- name: rename binaries
run: |
version_built=$(cat kindlecomicconverter/__init__.py | grep version | awk '{print $3}' | sed "s/[^.0-9b]//g")
mv dist/windows/${{ matrix.entry }}.exe dist/windows/${{ matrix.capital }}_${version_built}.exe
- name: upload-unsigned-artifact
id: upload-unsigned-artifact
uses: actions/upload-artifact@v4
with:
name: windows-build-${{ matrix.entry }}
path: dist/windows/*.exe
- id: optional_step_id
uses: signpath/github-action-submit-signing-request@v1.2
if: ${{ github.repository == 'ciromattia/kcc' }}
with:
api-token: '${{ secrets.SIGNPATH_API_TOKEN }}'
organization-id: '1dc1bad6-4a8c-4f85-af30-5c5d3d392ea6'
project-slug: 'kcc'
signing-policy-slug: 'release-signing'
github-artifact-id: '${{ steps.upload-unsigned-artifact.outputs.artifact-id }}'
wait-for-completion: true
output-artifact-directory: 'dist/windows/'
- name: Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
prerelease: true
generate_release_notes: true
files: |
LICENSE.txt
dist/windows/*.exe

View File

@@ -23,21 +23,11 @@ on:
jobs: jobs:
build: build:
strategy:
matrix:
entry: [ kcc, kcc-c2e, kcc-c2p ]
include:
- entry: kcc
command: build_binary
- entry: kcc-c2e
command: build_c2e
- entry: kcc-c2p
command: build_c2p
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v5
with: with:
python-version: 3.11 python-version: 3.11
cache: 'pip' cache: 'pip'
@@ -45,20 +35,20 @@ jobs:
env: env:
PYINSTALLER_COMPILE_BOOTLOADER: 1 PYINSTALLER_COMPILE_BOOTLOADER: 1
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip setuptools wheel
pip install -r requirements.txt pip install -r requirements.txt
pip install certifi pyinstaller --no-binary pyinstaller pip install certifi pyinstaller --no-binary pyinstaller
- name: build binary - name: build binary
run: | run: |
python setup.py ${{ matrix.command }} python setup.py build_binary
- name: upload-unsigned-artifact - name: upload-unsigned-artifact
id: upload-unsigned-artifact id: upload-unsigned-artifact
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v4
with: with:
name: windows-build-${{ matrix.entry }} name: windows-build
path: dist/*.exe path: dist/*.exe
- id: optional_step_id - id: optional_step_id
uses: signpath/github-action-submit-signing-request@v2.0 uses: signpath/github-action-submit-signing-request@v1.2
if: ${{ github.repository == 'ciromattia/kcc' }} if: ${{ github.repository == 'ciromattia/kcc' }}
with: with:
api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' api-token: '${{ secrets.SIGNPATH_API_TOKEN }}'
@@ -73,6 +63,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
with: with:
prerelease: true prerelease: true
generate_release_notes: false generate_release_notes: true
files: | files: |
LICENSE.txt
dist/*.exe dist/*.exe

View File

@@ -1,71 +0,0 @@
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
name: build KCC for windows 7
on:
workflow_dispatch:
push:
tags:
- "v*.*.*"
# Don't trigger if it's just a documentation update
paths-ignore:
- '**.md'
- '**.MD'
- '**.yml'
- '**.sh'
- 'docs/**'
- 'Dockerfile'
- 'LICENSE'
- '.gitattributes'
- '.gitignore'
- '.dockerignore'
jobs:
build:
runs-on: windows-2022
env:
WINDOWS_7: 1
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: 3.8
cache: 'pip'
- name: Install dependencies
env:
PYINSTALLER_COMPILE_BOOTLOADER: 1
run: |
python -m pip install --upgrade pip
pip install -r requirements-win7.txt
pip install certifi pyinstaller --no-binary pyinstaller
.\gen_ui_files.bat
- name: build binary
run: |
python setup.py build_binary
- name: upload-unsigned-artifact
id: upload-unsigned-artifact
uses: actions/upload-artifact@v6
with:
name: windows7-build
path: dist/*.exe
- id: optional_step_id
uses: signpath/github-action-submit-signing-request@v2.0
if: ${{ github.repository == 'ciromattia/kcc' }}
with:
api-token: '${{ secrets.SIGNPATH_API_TOKEN }}'
organization-id: '1dc1bad6-4a8c-4f85-af30-5c5d3d392ea6'
project-slug: 'kcc'
signing-policy-slug: 'release-signing'
github-artifact-id: '${{ steps.upload-unsigned-artifact.outputs.artifact-id }}'
wait-for-completion: true
output-artifact-directory: 'dist/'
- name: Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
prerelease: true
generate_release_notes: false
files: |
dist/*.exe

3
.gitignore vendored
View File

@@ -8,9 +8,6 @@ dist/
build/ build/
KindleComicConverter*.egg-info/ KindleComicConverter*.egg-info/
.idea/ .idea/
.vscode/
win7
osx10.11
/venv/ /venv/
/kindlegen* /kindlegen*
/kcc.bat /kcc.bat

View File

@@ -1,77 +1,19 @@
# STAGE 1: BUILDER # Select final stage based on TARGETARCH ARG
# Contains all build tools and dev dependencies, will be discarded FROM ghcr.io/ciromattia/kcc:docker-base-20241116
FROM python:3.13-slim-bullseye AS builder LABEL com.kcc.name="Kindle Comic Converter"
LABEL com.kcc.author="Ciro Mattia Gonano, Paweł Jastrzębski and Darodi"
LABEL org.opencontainers.image.description='Kindle Comic Converter'
LABEL org.opencontainers.image.documentation='https://github.com/ciromattia/kcc'
LABEL org.opencontainers.image.source='https://github.com/ciromattia/kcc'
LABEL org.opencontainers.image.authors='darodi'
LABEL org.opencontainers.image.url='https://github.com/ciromattia/kcc'
LABEL org.opencontainers.image.documentation='https://github.com/ciromattia/kcc'
LABEL org.opencontainers.image.vendor='ciromattia'
LABEL org.opencontainers.image.licenses='ISC'
LABEL org.opencontainers.image.title="Kindle Comic Converter"
# Install system dependencies COPY . /opt/kcc
RUN set -x && \ RUN cat /opt/kcc/kindlecomicconverter/__init__.py | grep version | awk '{print $3}' | sed "s/'//g" > /IMAGE_VERSION
BUILD_DEPS="build-essential cmake libffi-dev libfreetype6-dev libfontconfig1-dev libpng-dev libjpeg-dev libssl-dev libxft-dev make python3-dev" && \
RUNTIME_DEPS="bash ca-certificates chrpath locales locales-all libfreetype6 libfontconfig1 p7zip-full python3 python3-pip libgl1" && \
DEBIAN_FRONTEND=noninteractive apt-get update -y && \
apt-get install -y --no-install-recommends ${BUILD_DEPS} ${RUNTIME_DEPS}
RUN \ ENTRYPOINT ["/opt/kcc/kcc-c2e.py"]
set -x && \
python -m venv /opt/venv && \
. /opt/venv/bin/activate && \
pip install --upgrade pip
# Install numpy first, as it is unlikely to change and takes too long to compile
RUN \
set -x && \
. /opt/venv/bin/activate && \
pip install --no-cache-dir numpy==2.3.4
# Install PyMuPDF separately, as it is likely to change but still takes too long to compile
RUN \
set -x && \
. /opt/venv/bin/activate && \
pip install --no-cache-dir PyMuPDF==1.26.6
# Install Python dependencies using virtual environment
COPY requirements-docker.txt .
RUN \
set -x && \
. /opt/venv/bin/activate && \
pip install --no-cache-dir -r requirements-docker.txt
# STAGE 2: FINAL
# Clean, small and secure image with only runtime dependencies
FROM python:3.13-slim-bullseye
# Install runtime dependencies only
RUN \
set -x && \
DEBIAN_FRONTEND=noninteractive apt-get update -y && \
apt-get install -y --no-install-recommends p7zip-full && \
rm -rf /var/lib/apt/lists/*
# Copy artifacts from builder
COPY --from=builder /opt/venv /opt/venv
COPY . /opt/kcc/
WORKDIR /opt/kcc
ENV PATH="/opt/venv/bin:$PATH"
# Setup executable and version file
RUN \
chmod +x /opt/kcc/entrypoint.sh && \
ln -s /opt/kcc/kcc-c2e.py /usr/local/bin/c2e && \
ln -s /opt/kcc/kcc-c2p.py /usr/local/bin/c2p && \
ln -s /opt/kcc/entrypoint.sh /usr/local/bin/entrypoint && \
ln -s /opt/kcc/kindlegen/kindlegen /usr/local/bin/kindlegen && \
cat /opt/kcc/kindlecomicconverter/__init__.py | grep version | awk '{print $3}' | sed "s/'//g" > /IMAGE_VERSION
LABEL com.kcc.name="Kindle Comic Converter" \
com.kcc.author="Ciro Mattia Gonano, Paweł Jastrzębski and Darodi" \
org.opencontainers.image.title="Kindle Comic Converter" \
org.opencontainers.image.description='Kindle Comic Converter' \
org.opencontainers.image.documentation='https://github.com/ciromattia/kcc' \
org.opencontainers.image.source='https://github.com/ciromattia/kcc' \
org.opencontainers.image.authors='Darodi and José Cerezo' \
org.opencontainers.image.url='https://github.com/ciromattia/kcc' \
org.opencontainers.image.vendor='ciromattia' \
org.opencontainers.image.licenses='ISC'
ENTRYPOINT ["entrypoint"]
CMD ["-h"] CMD ["-h"]

164
Dockerfile-base Normal file
View File

@@ -0,0 +1,164 @@
FROM --platform=linux/amd64 python:3.13-slim-bullseye as compile-amd64
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
RUN echo "I'm building for $TARGETOS/$TARGETARCH/$TARGETVARIANT"
COPY requirements.txt /opt/kcc/
ENV PATH="/opt/venv/bin:$PATH"
RUN DEBIAN_FRONTEND=noninteractive apt-get update -y && apt-get -yq upgrade && \
apt-get install -y libpng-dev libjpeg-dev p7zip-full unrar-free libgl1 && \
python -m pip install --upgrade pip && \
python -m venv /opt/venv && \
python -m pip install -r /opt/kcc/requirements.txt
######################################################################################
FROM --platform=linux/arm64 python:3.13-slim-bullseye as compile-arm64
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
RUN echo "I'm building for $TARGETOS/$TARGETARCH/$TARGETVARIANT"
ENV LC_ALL=C.UTF-8 \
LANG=C.UTF-8 \
LANGUAGE=en_US:en
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
COPY requirements.txt /opt/kcc/
ENV PATH="/opt/venv/bin:$PATH"
RUN set -x && \
TEMP_PACKAGES=() && \
KEPT_PACKAGES=() && \
# Packages only required during build
TEMP_PACKAGES+=(build-essential) && \
TEMP_PACKAGES+=(cmake) && \
TEMP_PACKAGES+=(libfreetype6-dev) && \
TEMP_PACKAGES+=(libfontconfig1-dev) && \
TEMP_PACKAGES+=(libpng-dev) && \
TEMP_PACKAGES+=(libjpeg-dev) && \
TEMP_PACKAGES+=(libssl-dev) && \
TEMP_PACKAGES+=(libxft-dev) && \
TEMP_PACKAGES+=(make) && \
TEMP_PACKAGES+=(python3-dev) && \
TEMP_PACKAGES+=(python3-setuptools) && \
TEMP_PACKAGES+=(python3-wheel) && \
# Packages kept in the image
KEPT_PACKAGES+=(bash) && \
KEPT_PACKAGES+=(ca-certificates) && \
KEPT_PACKAGES+=(chrpath) && \
KEPT_PACKAGES+=(locales) && \
KEPT_PACKAGES+=(locales-all) && \
KEPT_PACKAGES+=(libfreetype6) && \
KEPT_PACKAGES+=(libfontconfig1) && \
KEPT_PACKAGES+=(p7zip-full) && \
KEPT_PACKAGES+=(python3) && \
KEPT_PACKAGES+=(python3-pip) && \
KEPT_PACKAGES+=(unrar-free) && \
# Install packages
DEBIAN_FRONTEND=noninteractive apt-get update -y && apt-get -yq upgrade && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
${KEPT_PACKAGES[@]} \
${TEMP_PACKAGES[@]} \
&& \
# Install required python modules
python -m pip install --upgrade pip && \
python -m venv /opt/venv && \
python -m pip install -r /opt/kcc/requirements.txt
######################################################################################
FROM --platform=linux/arm/v7 python:3.13-slim-bullseye as compile-armv7
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
RUN echo "I'm building for $TARGETOS/$TARGETARCH/$TARGETVARIANT"
ENV LC_ALL=C.UTF-8 \
LANG=C.UTF-8 \
LANGUAGE=en_US:en
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
COPY requirements.txt /opt/kcc/
ENV PATH="/opt/venv/bin:$PATH"
RUN set -x && \
TEMP_PACKAGES=() && \
KEPT_PACKAGES=() && \
# Packages only required during build
TEMP_PACKAGES+=(build-essential) && \
TEMP_PACKAGES+=(cmake) && \
TEMP_PACKAGES+=(libffi-dev) && \
TEMP_PACKAGES+=(libfreetype6-dev) && \
TEMP_PACKAGES+=(libfontconfig1-dev) && \
TEMP_PACKAGES+=(libpng-dev) && \
TEMP_PACKAGES+=(libjpeg-dev) && \
TEMP_PACKAGES+=(libssl-dev) && \
TEMP_PACKAGES+=(libxft-dev) && \
TEMP_PACKAGES+=(make) && \
TEMP_PACKAGES+=(python3-dev) && \
TEMP_PACKAGES+=(python3-setuptools) && \
TEMP_PACKAGES+=(python3-wheel) && \
# Packages kept in the image
KEPT_PACKAGES+=(bash) && \
KEPT_PACKAGES+=(ca-certificates) && \
KEPT_PACKAGES+=(chrpath) && \
KEPT_PACKAGES+=(locales) && \
KEPT_PACKAGES+=(locales-all) && \
KEPT_PACKAGES+=(libfreetype6) && \
KEPT_PACKAGES+=(libfontconfig1) && \
KEPT_PACKAGES+=(p7zip-full) && \
KEPT_PACKAGES+=(python3) && \
KEPT_PACKAGES+=(python3-pip) && \
KEPT_PACKAGES+=(unrar-free) && \
# Install packages
DEBIAN_FRONTEND=noninteractive apt-get update -y && apt-get -yq upgrade && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
${KEPT_PACKAGES[@]} \
${TEMP_PACKAGES[@]} \
&& \
# Install required python modules
python -m pip install --upgrade pip && \
python -m venv /opt/venv && \
python -m pip install --upgrade pillow psutil requests python-slugify raven packaging mozjpeg-lossless-optimization natsort distro numpy
######################################################################################
FROM --platform=linux/amd64 python:3.13-slim-bullseye as build-amd64
COPY --from=compile-amd64 /opt/venv /opt/venv
FROM --platform=linux/arm64 python:3.13-slim-bullseye as build-arm64
COPY --from=compile-arm64 /opt/venv /opt/venv
FROM --platform=linux/arm/v7 python:3.13-slim-bullseye as build-armv7
COPY --from=compile-armv7 /opt/venv /opt/venv
######################################################################################
# Select final stage based on TARGETARCH ARG
FROM build-${TARGETARCH}${TARGETVARIANT}
LABEL com.kcc.name="Kindle Comic Converter base image"
LABEL com.kcc.author="Ciro Mattia Gonano, Paweł Jastrzębski and Darodi"
LABEL org.opencontainers.image.description='Kindle Comic Converter base image'
LABEL org.opencontainers.image.documentation='https://github.com/ciromattia/kcc'
LABEL org.opencontainers.image.source='https://github.com/ciromattia/kcc'
LABEL org.opencontainers.image.authors='darodi'
LABEL org.opencontainers.image.url='https://github.com/ciromattia/kcc'
LABEL org.opencontainers.image.documentation='https://github.com/ciromattia/kcc'
LABEL org.opencontainers.image.vendor='ciromattia'
LABEL org.opencontainers.image.licenses='ISC'
LABEL org.opencontainers.image.title="Kindle Comic Converter"
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
RUN DEBIAN_FRONTEND=noninteractive apt-get update -y && apt-get -yq upgrade && \
apt-get install -y p7zip-full unrar-free && \
ln -s /app/kindlegen /bin/kindlegen && \
echo docker-base-20241116 > /IMAGE_VERSION

206
README.md
View File

@@ -2,61 +2,23 @@
# KCC # KCC
[![GitHub release](https://img.shields.io/github/release/ciromattia/kcc.svg)](https://github.com/ciromattia/kcc/releases) [![GitHub release](https://img.shields.io/github/release/ciromattia/kcc.svg)](https://github.com/ciromattia/kcc/releases)
[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/ciromattia/kcc/docker-publish.yml?label=docker%20build)](https://github.com/ciromattia/kcc/pkgs/container/kcc) [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/ciromattia/kcc/docker-publish.yml?label=docker%20build)](https://github.com/ciromattia/kcc/pkgs/container/kcc)
[![Github All Releases](https://img.shields.io/github/downloads/ciromattia/kcc/total.svg)](https://github.com/ciromattia/kcc/releases)
**Kindle Comic Converter** optimizes black & white (or color) comics and manga for E-ink ereaders
like Kindle, Kobo, ReMarkable, and more.
Pages display in fullscreen without margins,
with proper fixed layout support.
Supported input formats include JPG/PNG image files in folders, archives, or PDFs.
Supported output formats include MOBI/AZW3, EPUB, KEPUB, CBZ, and PDF.
KCC runs on Windows, macOS, and Linux.
Just drop your input files into the KCC window, hit convert, and USB drop the output files onto your device's `documents` folder!
https://github.com/user-attachments/assets/da73d625-e082-482d-91a4-ae4765e96fd7
**WARNING**: Kindle Scribe 2025 support may not be possible. Does not work well currently.
**NEW**: PDF output is now supported for direct conversion to reMarkable devices!
When using a reMarkable profile (Rmk1, Rmk2, RmkPP), the format automatically defaults to PDF
for optimal compatibility with your device's native PDF reader.
The absolute highest quality source files are print quality DRM-free PDFs from Kodansha/[Humble Bundle](https://humblebundleinc.sjv.io/xL6Zv1)/Fanatical,
which can be directly converted by KCC.
**Kindle Comic Converter** optimizes comics and manga for eink readers like Kindle, Kobo, ReMarkable, and more.
Pages display in fullscreen without margins, with proper fixed layout support.
Its main feature is various optional image processing steps to look good on eink screens, Its main feature is various optional image processing steps to look good on eink screens,
which have different requirements than normal LCD screens. which have different requirements than normal LCD screens.
Combining that with downscaling to your specific device's screen resolution It also does filesize optimization by downscaling to your specific device's screen resolution,
can result in filesize reductions of hundreds of MB per volume with no visible quality loss on eink. which can improve performance on underpowered ereaders.
This can also improve battery life, page turn speed, and general performance Supported input formats include folders and archives of JPG/PNG files and more.
on underpowered ereaders with small memory and storage capacities. Supported output formats include virtual panel view MOBI/AZW3, EPUB, KEPUB, and CBZ.
KCC avoids many common formatting issues (some of which occur [even on the Kindle Store](https://github.com/ciromattia/kcc/wiki/Kindle-Store-bad-formatting)), such as:
1) faded black levels causing unneccessarily low contrast, which is hard to see and can cause eyestrain.
2) unneccessary margins at the bottom of the screen
3) Not utilizing the full 1860x2480 resolution of the 10" Kindle Scribe
4) incorrect page turn direction for manga that's read right to left
5) unaligned two page spreads in landscape, where pages are shifted over by 1
6) Removing without blur the rainbow effect on color eink Kaleido 3 due to manga screentones
The GUI looks like this, built in Qt6, with my most commonly used settings:
![image](https://github.com/user-attachments/assets/36ad2131-6677-4559-bd6f-314a90c27218) ![image](https://github.com/user-attachments/assets/36ad2131-6677-4559-bd6f-314a90c27218)
Simply drag and drop your files/folders into the KCC window, YouTube tutorial (please subscribe): https://www.youtube.com/watch?v=IR2Fhcm9658
adjust your settings (hover over each option to see details in a tooltip),
and hit convert to create ereader optimized files.
You can change the default output directory by holding `Shift` while clicking the convert button.
Then just drag and drop the generated output files onto your device's documents folder via USB.
If you are on macOS and use a 2022+ Kindle, you may need to use Amazon USB File Manager for Mac.
YouTube tutorial (please subscribe): https://www.youtube.com/watch?v=QQ6zJcMF2Iw
Installation tutorial: https://www.youtube.com/watch?v=IR2Fhcm9658
### A word of warning ### A word of warning
**KCC** _is not_ [Amazon's Kindle Comic Creator](http://www.amazon.com/gp/feature.html?ie=UTF8&docId=1001103761) nor is in any way endorsed by Amazon. **KCC** _is not_ [Amazon's Kindle Comic Creator](http://www.amazon.com/gp/feature.html?ie=UTF8&docId=1001103761) nor is in any way endorsed by Amazon.
@@ -82,13 +44,6 @@ If you find **KCC** valuable you can consider donating to the authors:
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/Q5Q41BW8HS) [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/Q5Q41BW8HS)
## Commissions
This section is subject to change:
Email (for commisions and inquiries): `kindle.comic.converter` gmail
## Sponsors ## Sponsors
- Free code signing on Windows provided by [SignPath.io](https://about.signpath.io/), certificate by [SignPath Foundation](https://signpath.org/) - Free code signing on Windows provided by [SignPath.io](https://about.signpath.io/), certificate by [SignPath Foundation](https://signpath.org/)
@@ -102,44 +57,26 @@ Click on **Assets** of the latest release.
You probably want either You probably want either
- `KCC_*.*.*.exe` (Windows) - `KCC_*.*.*.exe` (Windows)
- `kcc_macos_arm_*.*.*.dmg` (recent Mac with Apple Silicon M1 chip or later) - `kcc_macos_arm_*.*.*.dmg` (recent Mac with Apple Silicon M1 chip or later)
- `kcc_macos_i386_*.*.*.dmg` (older Mac with Intel chip macOS 14+) - `kcc_macos_i386_*.*.*.dmg` (older Mac with Intel chip)
There are also legacy macOS 10.14+ and Windows 7 experimental versions available.
The `c2e` and `c2p` versions are command line tools for power users. The `c2e` and `c2p` versions are command line tools for power users.
On macOS, if you get a `can't be opened` error, follow: https://support.apple.com/guide/mac-help/open-a-mac-app-from-an-unknown-developer-mh40616/mac On Windows 11, you may need to run in compatibility mode for an older Windows version.
On Mac, right click open to get past the security warning.
For flatpak, Docker, and AppImage versions, refer to the wiki: https://github.com/ciromattia/kcc/wiki/Installation For flatpak, Docker, and AppImage versions, refer to the wiki: https://github.com/ciromattia/kcc/wiki/Installation
## FAQ ## FAQ
- Should I use Calibre? - [Windows 7 support](https://github.com/ciromattia/kcc/issues/678)
- No. Calibre doesn't properly support fixed layout EPUB/MOBI, so modifying KCC output (even just metadata!) in Calibre can break the formatting. - [Combine files/chapters](https://github.com/ciromattia/kcc/issues/612#issuecomment-2117985011)
Viewing KCC output in Calibre will also not work properly. - [Flatpak mobi conversion stuck](https://github.com/ciromattia/kcc/wiki/Installation#linux)
On 7th gen and later Kindles running firmware 5.15.1+, you can get cover thumbnails simply by USB dropping into documents folder. - Image too dark?
On 6th gen and older, you can get cover thumbnails by keeping Kindle plugged in during conversion. - The default gamma correction of 1.8 makes the image darker, and is useful for faded/gray artwork/text. Disable by setting gamma = 1.0
If you are careful to not modify the file however, you can still use Calibre, but direct USB dropping is reccomended. - [Better PDF support (Humble Bundle, Fanatical, etc)](https://github.com/ciromattia/kcc/issues/680)
- Blank pages?
- May happen when [using PNG with Kindle Scribe](https://github.com/ciromattia/kcc/issues/665) or [any format with a Kindle Colorsoft](https://github.com/ciromattia/kcc/issues/768). Solve by using JPG with Kindle Scribe or buying a Kobo Colour. Happens more often when turning pages really fast.
Going back a few pages and exiting and re-entering book should fix it temporarily.
- What output format should I use?
- MOBI for Kindles. CBZ for Kindle DX. CBZ for Koreader. KEPUB for Kobo. PDF for ReMarkable.
- All options have additional information in tooltips if you hover over the option.
- To get the converted book onto your Kindle/Kobo, just drag and drop the mobi/kepub into the documents folder on your Kindle/Kobo via USB
- Kindle panel view not working?
- Virtual panel view is enabled in Aa menu on your Kindle, not in KCC as of 7.4
- Right to left mode not working?
- RTL mode only affects splitting order for CBZ output. Your cbz reader itself sets the page turn direction.
- Colors inverted?
- Disable Kindle dark mode
- Cannot connect Kindle Scribe or 2024+ Kindle to macOS - Cannot connect Kindle Scribe or 2024+ Kindle to macOS
- Use official MTP [Amazon USB File Transfer app](https://www.amazon.com/gp/help/customer/display.html/ref=hp_Connect_USB_MTP?nodeId=TCUBEdEkbIhK07ysFu) - Use official MTP [Amazon USB File Transfer app](https://www.amazon.com/gp/help/customer/display.html/ref=hp_Connect_USB_MTP?nodeId=TCUBEdEkbIhK07ysFu)
(no login required). Works much better than previously recommended Android File Transfer. Cannot run simutaneously with other transfer apps. (no login required). Works much better than previously recommended Android File Transfer. Cannot run simutaneously with other transfer apps.
- How to make AZW3 instead of MOBI?
- The `.mobi` file generated by KCC is a dual filetype, it's both MOBI and AZW3. The file extension is `.mobi` for compatibility reasons.
- [Windows 7 support](https://github.com/ciromattia/kcc/issues/678)
- Image too dark?
- The default gamma correction of 1.8 makes the image darker, and is useful for faded/gray artwork/text. Disable by setting gamma = 1.0
- Huge margins / slow page turns? - Huge margins / slow page turns?
- You likely modified the file during transfer using a 3rd party app. Try simply dragging and dropping the final mobi/kepub file into the Kindle documents folder via USB. - You likely modified the file during transfer using a 3rd party app. Try simply dragging and dropping the final mobi/kepub file into the Kindle documents folder via USB.
@@ -187,44 +124,36 @@ sudo apt-get install python3 p7zip-full python3-pil python3-psutil python3-slugi
### Profiles: ### Profiles:
``` ```
'K1': ("Kindle 1", (600, 670), Palette4, 1.0), 'K1': ("Kindle 1", (600, 670), Palette4, 1.8),
'K2': ("Kindle 2", (600, 670), Palette15, 1.0), 'K11': ("Kindle 11", (1072, 1448), Palette16, 1.8),
'K11': ("Kindle 11", (1072, 1448), Palette16, 1.0), 'K2': ("Kindle 2", (600, 670), Palette15, 1.8),
'K34': ("Kindle Keyboard/Touch", (600, 800), Palette16, 1.0), 'K34': ("Kindle Keyboard/Touch", (600, 800), Palette16, 1.8),
'K57': ("Kindle 5/7", (600, 800), Palette16, 1.0), 'K578': ("Kindle", (600, 800), Palette16, 1.8),
'K810': ("Kindle 8/10", (600, 800), Palette16, 1.0), 'KDX': ("Kindle DX/DXG", (824, 1000), Palette16, 1.8),
'KDX': ("Kindle DX/DXG", (824, 1000), Palette16, 1.0), 'KPW': ("Kindle Paperwhite 1/2", (758, 1024), Palette16, 1.8),
'KPW': ("Kindle Paperwhite 1/2", (758, 1024), Palette16, 1.0), 'KV': ("Kindle Paperwhite 3/4/Voyage/Oasis", (1072, 1448), Palette16, 1.8),
'KV': ("Kindle Voyage", (1072, 1448), Palette16, 1.0), 'KPW5': ("Kindle Paperwhite 5/Signature Edition", (1236, 1648), Palette16, 1.8),
'KPW34': ("Kindle Paperwhite 3/4", (1072, 1448), Palette16, 1.0), 'KO': ("Kindle Oasis 2/3/Paperwhite 12/Colorsoft 12", (1264, 1680), Palette16, 1.8),
'KPW5': ("Kindle Paperwhite 5/Signature Edition", (1236, 1648), Palette16, 1.0), 'KS': ("Kindle Scribe", (1860, 2480), Palette16, 1.8),
'KO': ("Kindle Oasis 2/3/Paperwhite 12", (1264, 1680), Palette16, 1.0), 'KoMT': ("Kobo Mini/Touch", (600, 800), Palette16, 1.8),
'KCS': ("Kindle Colorsoft", (1264, 1680), Palette16, 1.0), 'KoG': ("Kobo Glo", (768, 1024), Palette16, 1.8),
'KS1860': ("Kindle 1860", (1860, 1920), Palette16, 1.0), 'KoGHD': ("Kobo Glo HD", (1072, 1448), Palette16, 1.8),
'KS1920': ("Kindle 1920", (1920, 1920), Palette16, 1.0), 'KoA': ("Kobo Aura", (758, 1024), Palette16, 1.8),
'KS': ("Kindle Scribe 1/2", (1860, 2480), Palette16, 1.0), 'KoAHD': ("Kobo Aura HD", (1080, 1440), Palette16, 1.8),
'KS3': ("Kindle Scribe 3", (1986, 2648), Palette16, 1.0), 'KoAH2O': ("Kobo Aura H2O", (1080, 1430), Palette16, 1.8),
'KSCS': ("Kindle Scribe Colorsoft", (1986, 2648), Palette16, 1.0), 'KoAO': ("Kobo Aura ONE", (1404, 1872), Palette16, 1.8),
'KoMT': ("Kobo Mini/Touch", (600, 800), Palette16, 1.0), 'KoN': ("Kobo Nia", (758, 1024), Palette16, 1.8),
'KoG': ("Kobo Glo", (768, 1024), Palette16, 1.0), 'KoC': ("Kobo Clara HD/Kobo Clara 2E", (1072, 1448), Palette16, 1.8),
'KoGHD': ("Kobo Glo HD", (1072, 1448), Palette16, 1.0), 'KoCC': ("Kobo Clara Colour", (1072, 1448), Palette16, 1.8),
'KoA': ("Kobo Aura", (758, 1024), Palette16, 1.0), 'KoL': ("Kobo Libra H2O/Kobo Libra 2", (1264, 1680), Palette16, 1.8),
'KoAHD': ("Kobo Aura HD", (1080, 1440), Palette16, 1.0), 'KoLC': ("Kobo Libra Colour", (1264, 1680), Palette16, 1.8),
'KoAH2O': ("Kobo Aura H2O", (1080, 1430), Palette16, 1.0), 'KoF': ("Kobo Forma", (1440, 1920), Palette16, 1.8),
'KoAO': ("Kobo Aura ONE", (1404, 1872), Palette16, 1.0), 'KoS': ("Kobo Sage", (1440, 1920), Palette16, 1.8),
'KoN': ("Kobo Nia", (758, 1024), Palette16, 1.0), 'KoE': ("Kobo Elipsa", (1404, 1872), Palette16, 1.8),
'KoC': ("Kobo Clara HD/Kobo Clara 2E", (1072, 1448), Palette16, 1.0), 'Rmk1': ("reMarkable 1", (1404, 1872), Palette16, 1.8),
'KoCC': ("Kobo Clara Colour", (1072, 1448), Palette16, 1.0), 'Rmk2': ("reMarkable 2", (1404, 1872), Palette16, 1.8),
'KoL': ("Kobo Libra H2O/Kobo Libra 2", (1264, 1680), Palette16, 1.0), 'RmkPP': ("reMarkable Paper Pro", (1620, 2160), Palette16, 1.8),
'KoLC': ("Kobo Libra Colour", (1264, 1680), Palette16, 1.0), 'OTHER': ("Other", (0, 0), Palette16, 1.8),
'KoF': ("Kobo Forma", (1440, 1920), Palette16, 1.0),
'KoS': ("Kobo Sage", (1440, 1920), Palette16, 1.0),
'KoE': ("Kobo Elipsa", (1404, 1872), Palette16, 1.0),
'Rmk1': ("reMarkable 1", (1404, 1872), Palette16, 1.0),
'Rmk2': ("reMarkable 2", (1404, 1872), Palette16, 1.0),
'RmkPP': ("reMarkable Paper Pro", (1620, 2160), Palette16, 1.0),
'RmkPPMove': ("reMarkable Paper Pro Move", (954, 1696), Palette16, 1.0),
'OTHER': ("Other", (0, 0), Palette16, 1.0),
``` ```
### Standalone `kcc-c2e.py` usage: ### Standalone `kcc-c2e.py` usage:
@@ -247,18 +176,13 @@ MAIN:
the maximal size of output file in MB. [Default=100MB for webtoon and 400MB for others] the maximal size of output file in MB. [Default=100MB for webtoon and 400MB for others]
PROCESSING: PROCESSING:
-n, --noprocessing Do not modify image and ignore any profile or processing option -n, --noprocessing Do not modify image and ignore any profil or processing option
--pdfextract Use legacy PDF image extraction method from KCC 8 and earlier.
--pdfwidth Render vector PDFs based on device width instead of height.
-u, --upscale Resize images smaller than device's resolution -u, --upscale Resize images smaller than device's resolution
-s, --stretch Stretch images to device's resolution -s, --stretch Stretch images to device's resolution
-r SPLITTER, --splitter SPLITTER -r SPLITTER, --splitter SPLITTER
Double page parsing mode. 0: Split 1: Rotate 2: Both [Default=0] Double page parsing mode. 0: Split 1: Rotate 2: Both [Default=0]
-g GAMMA, --gamma GAMMA -g GAMMA, --gamma GAMMA
Apply gamma correction to linearize the image [Default=Auto] Apply gamma correction to linearize the image [Default=Auto]
--autolevel Set most common dark pixel value to be black point for leveling.
--noautocontrast Disable autocontrast
--colorautocontrast Force autocontrast for all pages. Skipped when near blacks and whites don't exist
-c CROPPING, --cropping CROPPING -c CROPPING, --cropping CROPPING
Set cropping mode. 0: Disabled 1: Margins 2: Margins + page numbers [Default=2] Set cropping mode. 0: Disabled 1: Margins 2: Margins + page numbers [Default=2]
--cp CROPPINGP, --croppingpower CROPPINGP --cp CROPPINGP, --croppingpower CROPPINGP
@@ -270,16 +194,9 @@ PROCESSING:
Crop empty sections. 0: Disabled 1: Horizontally 2: Both [Default=0] Crop empty sections. 0: Disabled 1: Horizontally 2: Both [Default=0]
--blackborders Disable autodetection and force black borders --blackborders Disable autodetection and force black borders
--whiteborders Disable autodetection and force white borders --whiteborders Disable autodetection and force white borders
--smartcovercrop Attempt to crop main cover from wide image
--coverfill Center-crop only the cover to fill target device screen
--forcecolor Don't convert images to grayscale --forcecolor Don't convert images to grayscale
--forcepng Create PNG files instead JPEG for black and white images --forcepng Create PNG files instead JPEG
--webp Replace JPG with lossy WEBP and PNG with lossless WEBP
--force-png-rgb Force color images to be saved as PNG
--pnglegacy Use a more compatible 8 bit PNG instead of 4 bit.
--noquantize Don't quantize PNG images to 16 colors
--mozjpeg Create JPEG files using mozJpeg --mozjpeg Create JPEG files using mozJpeg
--jpeg-quality The JPEG quality, on a scale from 0 (worst) to 95 (best). Default 85 for most devices.
--maximizestrips Turn 1x4 strips to 2x2 strips --maximizestrips Turn 1x4 strips to 2x2 strips
-d, --delete Delete source file(s) or a directory. It's not recoverable. -d, --delete Delete source file(s) or a directory. It's not recoverable.
@@ -288,20 +205,16 @@ OUTPUT SETTINGS:
Output generated file to specified directory or file Output generated file to specified directory or file
-t TITLE, --title TITLE -t TITLE, --title TITLE
Comic title [Default=filename or directory name] Comic title [Default=filename or directory name]
--metadatatitle Write title using ComicInfo.xml or other embedded metadata. 0: Don't use Title from metadata 1: Combine Title with default schema 2: Use Title only [Default=0]
-a AUTHOR, --author AUTHOR -a AUTHOR, --author AUTHOR
Author name [Default=KCC] Author name [Default=KCC]
-f FORMAT, --format FORMAT -f FORMAT, --format FORMAT
Output format (Available options: Auto, MOBI, EPUB, CBZ, PDF, KFX, MOBI+EPUB) [Default=Auto] Output format (Available options: Auto, MOBI, EPUB, CBZ, KFX, MOBI+EPUB) [Default=Auto]
--nokepub If format is EPUB, output file with '.epub' extension rather than '.kepub.epub' --nokepub If format is EPUB, output file with '.epub' extension rather than '.kepub.epub'
-b BATCHSPLIT, --batchsplit BATCHSPLIT -b BATCHSPLIT, --batchsplit BATCHSPLIT
Split output into multiple files. 0: Don't split 1: Automatic mode 2: Consider every subdirectory as separate volume [Default=0] Split output into multiple files. 0: Don't split 1: Automatic mode 2: Consider every subdirectory as separate volume [Default=0]
--spreadshift Shift first page to opposite side in landscape for two page spread alignment --spreadshift Shift first page to opposite side in landscape for two page spread alignment
--norotate Do not rotate double page spreads in spread splitter option. --norotate Do not rotate double page spreads in spread splitter option.
--rotateright Rotate double page spreads in opposite direction. --reducerainbow Reduce rainbow effect on color eink by slightly blurring images
--rotatefirst Put rotated spread first in spread splitter option.
--filefusion Combines all input files into a single file.
--eraserainbow Erase rainbow effect on color eink screen by attenuating interfering frequencies
CUSTOM PROFILE: CUSTOM PROFILE:
--customwidth CUSTOMWIDTH --customwidth CUSTOMWIDTH
@@ -344,20 +257,14 @@ Depending on your system [Python](https://www.python.org) may be called either `
If you want to edit the code, a good code editor is [VS Code](https://code.visualstudio.com). If you want to edit the code, a good code editor is [VS Code](https://code.visualstudio.com).
If you want to edit the `.ui` files, use `pyside6-designer` which is included in the `pip install pyside6`. If you want to edit the `.ui` files, use `pyside6-designer` which is included in the `pip install pyside6`.
If new objects have been added, verify that correct tab order has been applied by using [Tab Order Editing Mode](https://doc.qt.io/qt-6/designer-tab-order.html).
Then use the `gen_ui_files` scripts to autogenerate the python UI. Then use the `gen_ui_files` scripts to autogenerate the python UI.
An example PR adding a new checkbox is here: https://github.com/ciromattia/kcc/pull/785 An example PR adding a new checkbox is here: https://github.com/ciromattia/kcc/pull/785
video of adding a new checkbox: https://youtu.be/g3I8DU74C7g
Do not use `git merge` to merge master from upstream, Do not use `git merge` to merge master from upstream,
use the "Sync fork" button on your fork on GitHub in your branch use the "Sync fork" button on your fork on GitHub in your branch
to avoid weird looking merges in pull requests. to avoid weird looking merges in pull requests.
When making changes, be aware of how your change might affect file splitting/chunking
or chapter alignment.
### Windows install from source ### Windows install from source
One time setup and running for the first time: One time setup and running for the first time:
@@ -383,8 +290,6 @@ python setup.py build_binary
### macOS install from source ### macOS install from source
If the system installed Python gives you issues, please install the latest Python from either brew or the official website.
One time setup and running for the first time: One time setup and running for the first time:
``` ```
python3 -m venv venv python3 -m venv venv
@@ -441,7 +346,7 @@ Older links (dead):
## PRIVACY ## PRIVACY
**KCC** is initiating internet connections in two cases: **KCC** is initiating internet connections in two cases:
* During startup - Version check and announcement check. * During startup - Version check.
* When error occurs - Automatic reporting on Windows and macOS. * When error occurs - Automatic reporting on Windows and macOS.
## KNOWN ISSUES ## KNOWN ISSUES
@@ -450,6 +355,3 @@ Please check [wiki page](https://github.com/ciromattia/kcc/wiki/Known-issues).
## COPYRIGHT ## COPYRIGHT
Copyright (c) 2012-2025 Ciro Mattia Gonano, Paweł Jastrzębski, Darodi and Alex Xu. Copyright (c) 2012-2025 Ciro Mattia Gonano, Paweł Jastrzębski, Darodi and Alex Xu.
**KCC** is released under ISC LICENSE; see [LICENSE.txt](./LICENSE.txt) for further details. **KCC** is released under ISC LICENSE; see [LICENSE.txt](./LICENSE.txt) for further details.
## Verification
Impact-Site-Verification: ffe48fc7-4f0c-40fd-bd2e-59f4d7205180

View File

@@ -1,22 +0,0 @@
#!/bin/sh
set -e
MODE=${KCC_MODE:-c2e}
case "$MODE" in
"c2e")
echo "Starting C2E..."
exec c2e "$@"
;;
"c2p")
echo "Starting C2P..."
exec c2p "$@"
;;
*)
echo "Error: Unknown mode '$MODE'" >&2
exit 1
;;
esac

16
environment.yml Normal file
View File

@@ -0,0 +1,16 @@
name: kcc
channels:
- conda-forge
- defaults
dependencies:
- python=3.11
- Pillow>=5.2.0
- psutil>=5.9.5
- python-slugify>=1.2.1
- raven>=6.0.0
- distro
- natsort>=8.4.0
- pip
- pip:
- mozjpeg-lossless-optimization>=1.1.2
- pyside6>=6.5.1

View File

@@ -28,9 +28,4 @@
<file>../icons/document_new.png</file> <file>../icons/document_new.png</file>
<file>../icons/folder_new.png</file> <file>../icons/folder_new.png</file>
</qresource> </qresource>
<qresource prefix="Brand">
<file>../icons/kofi_symbol.png</file>
<file>../icons/Humble_H-Red.png</file>
<file>../icons/Bindle_Red.png</file>
</qresource>
</RCC> </RCC>

1556
gui/KCC.ui

File diff suppressed because it is too large Load Diff

View File

@@ -62,66 +62,56 @@
<item row="1" column="1"> <item row="1" column="1">
<widget class="QLineEdit" name="volumeLine"/> <widget class="QLineEdit" name="volumeLine"/>
</item> </item>
<item row="3" column="0"> <item row="2" column="0">
<widget class="QLabel" name="label_3"> <widget class="QLabel" name="label_3">
<property name="text"> <property name="text">
<string>Number:</string> <string>Number:</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="3" column="1"> <item row="2" column="1">
<widget class="QLineEdit" name="numberLine"/> <widget class="QLineEdit" name="numberLine"/>
</item> </item>
<item row="4" column="0"> <item row="3" column="0">
<widget class="QLabel" name="label_4"> <widget class="QLabel" name="label_4">
<property name="text"> <property name="text">
<string>Writer:</string> <string>Writer:</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="4" column="1"> <item row="3" column="1">
<widget class="QLineEdit" name="writerLine"/> <widget class="QLineEdit" name="writerLine"/>
</item> </item>
<item row="5" column="0"> <item row="4" column="0">
<widget class="QLabel" name="label_5"> <widget class="QLabel" name="label_5">
<property name="text"> <property name="text">
<string>Penciller:</string> <string>Penciller:</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="5" column="1"> <item row="4" column="1">
<widget class="QLineEdit" name="pencillerLine"/> <widget class="QLineEdit" name="pencillerLine"/>
</item> </item>
<item row="6" column="0"> <item row="5" column="0">
<widget class="QLabel" name="label_6"> <widget class="QLabel" name="label_6">
<property name="text"> <property name="text">
<string>Inker:</string> <string>Inker:</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="6" column="1"> <item row="5" column="1">
<widget class="QLineEdit" name="inkerLine"/> <widget class="QLineEdit" name="inkerLine"/>
</item> </item>
<item row="7" column="0"> <item row="6" column="0">
<widget class="QLabel" name="label_7"> <widget class="QLabel" name="label_7">
<property name="text"> <property name="text">
<string>Colorist:</string> <string>Colorist:</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="7" column="1"> <item row="6" column="1">
<widget class="QLineEdit" name="coloristLine"/> <widget class="QLineEdit" name="coloristLine"/>
</item> </item>
<item row="2" column="0">
<widget class="QLabel" name="label_8">
<property name="text">
<string>Title:</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="titleLine"/>
</item>
</layout> </layout>
</widget> </widget>
</item> </item>
@@ -192,18 +182,6 @@
</item> </item>
</layout> </layout>
</widget> </widget>
<tabstops>
<tabstop>seriesLine</tabstop>
<tabstop>volumeLine</tabstop>
<tabstop>titleLine</tabstop>
<tabstop>numberLine</tabstop>
<tabstop>writerLine</tabstop>
<tabstop>pencillerLine</tabstop>
<tabstop>inkerLine</tabstop>
<tabstop>coloristLine</tabstop>
<tabstop>okButton</tabstop>
<tabstop>cancelButton</tabstop>
</tabstops>
<resources> <resources>
<include location="KCC.qrc"/> <include location="KCC.qrc"/>
</resources> </resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -11,7 +11,7 @@ a = Analysis(['kcc-c2e.py'],
hiddenimports=['_cffi_backend'], hiddenimports=['_cffi_backend'],
hookspath=[], hookspath=[],
runtime_hooks=[], runtime_hooks=[],
excludes=['pkg_resources'], excludes=[],
win_no_prefer_redirects=False, win_no_prefer_redirects=False,
win_private_assemblies=False, win_private_assemblies=False,
cipher=block_cipher, cipher=block_cipher,

View File

@@ -11,7 +11,7 @@ a = Analysis(['kcc-c2p.py'],
hiddenimports=['_cffi_backend'], hiddenimports=['_cffi_backend'],
hookspath=[], hookspath=[],
runtime_hooks=[], runtime_hooks=[],
excludes=['pkg_resources'], excludes=[],
win_no_prefer_redirects=False, win_no_prefer_redirects=False,
win_private_assemblies=False, win_private_assemblies=False,
cipher=block_cipher, cipher=block_cipher,

View File

@@ -11,7 +11,7 @@ a = Analysis(['kcc.py'],
hiddenimports=['_cffi_backend'], hiddenimports=['_cffi_backend'],
hookspath=[], hookspath=[],
runtime_hooks=[], runtime_hooks=[],
excludes=['pkg_resources'], excludes=[],
win_no_prefer_redirects=False, win_no_prefer_redirects=False,
win_private_assemblies=False, win_private_assemblies=False,
cipher=block_cipher, cipher=block_cipher,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
################################################################################ ################################################################################
## Form generated from reading UI file 'MetaEditor.ui' ## Form generated from reading UI file 'MetaEditor.ui'
## ##
## Created by: Qt User Interface Compiler version 6.9.3 ## Created by: Qt User Interface Compiler version 6.8.2
## ##
## WARNING! All changes made in this file will be lost when recompiling UI file! ## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################ ################################################################################
@@ -60,62 +60,52 @@ class Ui_editorDialog(object):
self.label_3 = QLabel(self.editorWidget) self.label_3 = QLabel(self.editorWidget)
self.label_3.setObjectName(u"label_3") self.label_3.setObjectName(u"label_3")
self.gridLayout.addWidget(self.label_3, 3, 0, 1, 1) self.gridLayout.addWidget(self.label_3, 2, 0, 1, 1)
self.numberLine = QLineEdit(self.editorWidget) self.numberLine = QLineEdit(self.editorWidget)
self.numberLine.setObjectName(u"numberLine") self.numberLine.setObjectName(u"numberLine")
self.gridLayout.addWidget(self.numberLine, 3, 1, 1, 1) self.gridLayout.addWidget(self.numberLine, 2, 1, 1, 1)
self.label_4 = QLabel(self.editorWidget) self.label_4 = QLabel(self.editorWidget)
self.label_4.setObjectName(u"label_4") self.label_4.setObjectName(u"label_4")
self.gridLayout.addWidget(self.label_4, 4, 0, 1, 1) self.gridLayout.addWidget(self.label_4, 3, 0, 1, 1)
self.writerLine = QLineEdit(self.editorWidget) self.writerLine = QLineEdit(self.editorWidget)
self.writerLine.setObjectName(u"writerLine") self.writerLine.setObjectName(u"writerLine")
self.gridLayout.addWidget(self.writerLine, 4, 1, 1, 1) self.gridLayout.addWidget(self.writerLine, 3, 1, 1, 1)
self.label_5 = QLabel(self.editorWidget) self.label_5 = QLabel(self.editorWidget)
self.label_5.setObjectName(u"label_5") self.label_5.setObjectName(u"label_5")
self.gridLayout.addWidget(self.label_5, 5, 0, 1, 1) self.gridLayout.addWidget(self.label_5, 4, 0, 1, 1)
self.pencillerLine = QLineEdit(self.editorWidget) self.pencillerLine = QLineEdit(self.editorWidget)
self.pencillerLine.setObjectName(u"pencillerLine") self.pencillerLine.setObjectName(u"pencillerLine")
self.gridLayout.addWidget(self.pencillerLine, 5, 1, 1, 1) self.gridLayout.addWidget(self.pencillerLine, 4, 1, 1, 1)
self.label_6 = QLabel(self.editorWidget) self.label_6 = QLabel(self.editorWidget)
self.label_6.setObjectName(u"label_6") self.label_6.setObjectName(u"label_6")
self.gridLayout.addWidget(self.label_6, 6, 0, 1, 1) self.gridLayout.addWidget(self.label_6, 5, 0, 1, 1)
self.inkerLine = QLineEdit(self.editorWidget) self.inkerLine = QLineEdit(self.editorWidget)
self.inkerLine.setObjectName(u"inkerLine") self.inkerLine.setObjectName(u"inkerLine")
self.gridLayout.addWidget(self.inkerLine, 6, 1, 1, 1) self.gridLayout.addWidget(self.inkerLine, 5, 1, 1, 1)
self.label_7 = QLabel(self.editorWidget) self.label_7 = QLabel(self.editorWidget)
self.label_7.setObjectName(u"label_7") self.label_7.setObjectName(u"label_7")
self.gridLayout.addWidget(self.label_7, 7, 0, 1, 1) self.gridLayout.addWidget(self.label_7, 6, 0, 1, 1)
self.coloristLine = QLineEdit(self.editorWidget) self.coloristLine = QLineEdit(self.editorWidget)
self.coloristLine.setObjectName(u"coloristLine") self.coloristLine.setObjectName(u"coloristLine")
self.gridLayout.addWidget(self.coloristLine, 7, 1, 1, 1) self.gridLayout.addWidget(self.coloristLine, 6, 1, 1, 1)
self.label_8 = QLabel(self.editorWidget)
self.label_8.setObjectName(u"label_8")
self.gridLayout.addWidget(self.label_8, 2, 0, 1, 1)
self.titleLine = QLineEdit(self.editorWidget)
self.titleLine.setObjectName(u"titleLine")
self.gridLayout.addWidget(self.titleLine, 2, 1, 1, 1)
self.verticalLayout.addWidget(self.editorWidget) self.verticalLayout.addWidget(self.editorWidget)
@@ -156,15 +146,6 @@ class Ui_editorDialog(object):
self.verticalLayout.addWidget(self.optionWidget) self.verticalLayout.addWidget(self.optionWidget)
QWidget.setTabOrder(self.seriesLine, self.volumeLine)
QWidget.setTabOrder(self.volumeLine, self.titleLine)
QWidget.setTabOrder(self.titleLine, self.numberLine)
QWidget.setTabOrder(self.numberLine, self.writerLine)
QWidget.setTabOrder(self.writerLine, self.pencillerLine)
QWidget.setTabOrder(self.pencillerLine, self.inkerLine)
QWidget.setTabOrder(self.inkerLine, self.coloristLine)
QWidget.setTabOrder(self.coloristLine, self.okButton)
QWidget.setTabOrder(self.okButton, self.cancelButton)
self.retranslateUi(editorDialog) self.retranslateUi(editorDialog)
@@ -180,7 +161,6 @@ class Ui_editorDialog(object):
self.label_5.setText(QCoreApplication.translate("editorDialog", u"Penciller:", None)) self.label_5.setText(QCoreApplication.translate("editorDialog", u"Penciller:", None))
self.label_6.setText(QCoreApplication.translate("editorDialog", u"Inker:", None)) self.label_6.setText(QCoreApplication.translate("editorDialog", u"Inker:", None))
self.label_7.setText(QCoreApplication.translate("editorDialog", u"Colorist:", None)) self.label_7.setText(QCoreApplication.translate("editorDialog", u"Colorist:", None))
self.label_8.setText(QCoreApplication.translate("editorDialog", u"Title:", None))
self.statusLabel.setText("") self.statusLabel.setText("")
self.okButton.setText(QCoreApplication.translate("editorDialog", u"Save", None)) self.okButton.setText(QCoreApplication.translate("editorDialog", u"Save", None))
self.cancelButton.setText(QCoreApplication.translate("editorDialog", u"Cancel", None)) self.cancelButton.setText(QCoreApplication.translate("editorDialog", u"Cancel", None))

View File

@@ -1,4 +1,4 @@
__version__ = '9.7.0' __version__ = '7.4.1'
__license__ = 'ISC' __license__ = 'ISC'
__copyright__ = '2012-2022, Ciro Mattia Gonano <ciromattia@gmail.com>, Pawel Jastrzebski <pawelj@iosphe.re>, darodi' __copyright__ = '2012-2022, Ciro Mattia Gonano <ciromattia@gmail.com>, Pawel Jastrzebski <pawelj@iosphe.re>, darodi'
__docformat__ = 'restructuredtext en' __docformat__ = 'restructuredtext en'

File diff suppressed because it is too large Load Diff

View File

@@ -18,17 +18,13 @@
# PERFORMANCE OF THIS SOFTWARE. # PERFORMANCE OF THIS SOFTWARE.
# #
import math
import os import os
import sys import sys
from argparse import ArgumentParser from argparse import ArgumentParser
from shutil import rmtree from shutil import rmtree, copytree, move
from multiprocessing import Pool from multiprocessing import Pool
from PIL import Image, ImageChops, ImageOps, ImageDraw, ImageFilter, ImageFile from PIL import Image, ImageChops, ImageOps, ImageDraw
from PIL.Image import Dither from .shared import getImageFileName, walkLevel, walkSort, sanitizeTrace
from .shared import dot_clean, getImageFileName, walkLevel, walkSort, sanitizeTrace
ImageFile.LOAD_TRUNCATED_IMAGES = True
def mergeDirectoryTick(output): def mergeDirectoryTick(output):
@@ -48,7 +44,6 @@ def mergeDirectory(work):
imagesValid = [] imagesValid = []
sizes = [] sizes = []
targetHeight = 0 targetHeight = 0
dot_clean(directory)
for root, _, files in walkLevel(directory, 0): for root, _, files in walkLevel(directory, 0):
for name in files: for name in files:
if getImageFileName(name) is not None: if getImageFileName(name) is not None:
@@ -62,19 +57,18 @@ def mergeDirectory(work):
imagesValid.append(i[0]) imagesValid.append(i[0])
# Silently drop directories that contain too many images # Silently drop directories that contain too many images
# 131072 = GIMP_MAX_IMAGE_SIZE / 4 # 131072 = GIMP_MAX_IMAGE_SIZE / 4
if targetHeight > 131072 * 4: if targetHeight > 131072:
raise RuntimeError(f'Image too tall at {targetHeight} pixels. {targetWidth} pixels wide. Try using separate chapter folders or file fusion.') return None
result = Image.new('RGB', (targetWidth, targetHeight)) result = Image.new('RGB', (targetWidth, targetHeight))
y = 0 y = 0
for i in imagesValid: for i in imagesValid:
with Image.open(i) as img: img = Image.open(i).convert('RGB')
img = img.convert('RGB') if img.size[0] < targetWidth or img.size[0] > targetWidth:
if img.size[0] < targetWidth or img.size[0] > targetWidth: widthPercent = (targetWidth / float(img.size[0]))
widthPercent = (targetWidth / float(img.size[0])) heightSize = int((float(img.size[1]) * float(widthPercent)))
heightSize = int((float(img.size[1]) * float(widthPercent))) img = ImageOps.fit(img, (targetWidth, heightSize), method=Image.BICUBIC, centering=(0.5, 0.5))
img = ImageOps.fit(img, (targetWidth, heightSize), method=Image.BICUBIC, centering=(0.5, 0.5)) result.paste(img, (0, y))
result.paste(img, (0, y)) y += img.size[1]
y += img.size[1]
os.remove(i) os.remove(i)
savePath = os.path.split(imagesValid[0]) savePath = os.path.split(imagesValid[0])
result.save(os.path.join(savePath[0], os.path.splitext(savePath[1])[0] + '.png'), 'PNG') result.save(os.path.join(savePath[0], os.path.splitext(savePath[1])[0] + '.png'), 'PNG')
@@ -106,11 +100,7 @@ def splitImage(work):
Image.warnings.simplefilter('error', Image.DecompressionBombWarning) Image.warnings.simplefilter('error', Image.DecompressionBombWarning)
Image.MAX_IMAGE_PIXELS = 1000000000 Image.MAX_IMAGE_PIXELS = 1000000000
imgOrg = Image.open(filePath).convert('RGB') imgOrg = Image.open(filePath).convert('RGB')
# I experimented with custom vertical edge kernel [-1, 2, -1] but got poor results imgProcess = Image.open(filePath).convert('1')
imgEdges = Image.open(filePath).convert('L').filter(ImageFilter.FIND_EDGES)
# threshold of 8 is too high. 5 is too low.
imgProcess = imgEdges.point(lambda p: 255 if p > 6 else 0).convert('1', dither=Dither.NONE)
widthImg, heightImg = imgOrg.size widthImg, heightImg = imgOrg.size
if heightImg > opt.height: if heightImg > opt.height:
if opt.debug: if opt.debug:
@@ -121,71 +111,47 @@ def splitImage(work):
yWork = 0 yWork = 0
panelDetected = False panelDetected = False
panels = [] panels = []
# check git history for how these constant values changed
h_pad = int(widthImg / 20)
v_pad = int(widthImg / 80)
if v_pad % 2:
v_pad += 1
while yWork < heightImg: while yWork < heightImg:
tmpImg = imgProcess.crop((h_pad, yWork, widthImg - h_pad, yWork + v_pad)) tmpImg = imgProcess.crop((4, yWork, widthImg-4, yWork + 4))
solid = detectSolid(tmpImg) solid = detectSolid(tmpImg)
if not solid and not panelDetected: if not solid and not panelDetected:
panelDetected = True panelDetected = True
panelY1 = yWork panelY1 = yWork - 2
if heightImg - yWork <= (v_pad // 2): if heightImg - yWork <= 5:
if not solid and panelDetected: if not solid and panelDetected:
panelY2 = heightImg panelY2 = heightImg
panelDetected = False panelDetected = False
panels.append((panelY1, panelY2, panelY2 - panelY1)) panels.append((panelY1, panelY2, panelY2 - panelY1))
if solid and panelDetected: if solid and panelDetected:
panelDetected = False panelDetected = False
panelY2 = yWork panelY2 = yWork + 6
# skip short panel at start
if panelY1 < v_pad * 2 and panelY2 - panelY1 < v_pad * 2:
continue
panels.append((panelY1, panelY2, panelY2 - panelY1)) panels.append((panelY1, panelY2, panelY2 - panelY1))
yWork += v_pad // 2 yWork += 5
max_width = 1072
virtual_width = min((max_width, opt.width, widthImg))
if opt.width > max_width:
virtual_height = int(opt.height/max_width*virtual_width)
else:
virtual_height = int(opt.height/opt.width*virtual_width)
opt.height = virtual_height
# Split too big panels # Split too big panels
panelsProcessed = [] panelsProcessed = []
for panel in panels: for panel in panels:
# 1.52 too high
if panel[2] <= opt.height * 1.5: if panel[2] <= opt.height * 1.5:
panelsProcessed.append(panel) panelsProcessed.append(panel)
elif panel[2] <= opt.height * 2: elif panel[2] < opt.height * 2:
diff = panel[2] - opt.height diff = panel[2] - opt.height
panelsProcessed.append((panel[0], panel[1] - diff, opt.height)) panelsProcessed.append((panel[0], panel[1] - diff, opt.height))
panelsProcessed.append((panel[1] - opt.height, panel[1], opt.height)) panelsProcessed.append((panel[1] - opt.height, panel[1], opt.height))
else: else:
# split super long panels with overlap parts = round(panel[2] / opt.height)
parts = math.ceil(panel[2] / opt.height)
diff = panel[2] // parts diff = panel[2] // parts
panelsProcessed.append((panel[0], panel[0] + opt.height, opt.height)) for x in range(0, parts):
for x in range(1, parts - 1): panelsProcessed.append((panel[0] + (x * diff), panel[1] - ((parts - x - 1) * diff), diff))
start = panel[0] + (x * diff)
panelsProcessed.append((start, start + opt.height, opt.height))
panelsProcessed.append((panel[1] - opt.height, panel[1], opt.height))
if opt.debug: if opt.debug:
for panel in panelsProcessed: for panel in panelsProcessed:
draw.rectangle(((0, panel[0]), (widthImg, panel[1])), (0, 255, 0, 128), (0, 0, 255, 255)) draw.rectangle(((0, panel[0]), (widthImg, panel[1])), (0, 255, 0, 128), (0, 0, 255, 255))
debugImage = Image.alpha_composite(imgOrg.convert(mode='RGBA'), drawImg) debugImage = Image.alpha_composite(imgOrg.convert(mode='RGBA'), drawImg)
# debugImage.show()
debugImage.save(os.path.join(path, os.path.splitext(name)[0] + '-debug.png'), 'PNG') debugImage.save(os.path.join(path, os.path.splitext(name)[0] + '-debug.png'), 'PNG')
# Create virtual pages # Create virtual pages
pages = [] pages = []
currentPage = [] currentPage = []
# TODO: 1.25 way too high, 1.1 too high, 1.05 slightly too high(?), optimized for 2 page landscape reading
# opt.height = max_height = virtual_height * 1.00
pageLeft = opt.height pageLeft = opt.height
panelNumber = 0 panelNumber = 0
for panel in panelsProcessed: for panel in panelsProcessed:
@@ -222,7 +188,7 @@ def splitImage(work):
return str(sys.exc_info()[1]), sanitizeTrace(sys.exc_info()[2]) return str(sys.exc_info()[1]), sanitizeTrace(sys.exc_info()[2])
def main(argv=None, job_progress='', qtgui=None): def main(argv=None, qtgui=None):
global args, GUI, splitWorkerPool, splitWorkerOutput, mergeWorkerPool, mergeWorkerOutput global args, GUI, splitWorkerPool, splitWorkerOutput, mergeWorkerPool, mergeWorkerOutput
parser = ArgumentParser(prog="kcc-c2p", usage="kcc-c2p [options] [input]", add_help=False) parser = ArgumentParser(prog="kcc-c2p", usage="kcc-c2p [options] [input]", add_help=False)
@@ -234,8 +200,6 @@ def main(argv=None, job_progress='', qtgui=None):
" with spaces.") " with spaces.")
main_options.add_argument("-y", "--height", type=int, dest="height", default=0, main_options.add_argument("-y", "--height", type=int, dest="height", default=0,
help="Height of the target device screen") help="Height of the target device screen")
main_options.add_argument("-x", "--width", type=int, dest="width", default=0,
help="Width of the target device screen")
main_options.add_argument("-i", "--in-place", action="store_true", dest="inPlace", default=False, main_options.add_argument("-i", "--in-place", action="store_true", dest="inPlace", default=False,
help="Overwrite source directory") help="Overwrite source directory")
main_options.add_argument("-m", "--merge", action="store_true", dest="merge", default=False, main_options.add_argument("-m", "--merge", action="store_true", dest="merge", default=False,
@@ -254,14 +218,16 @@ def main(argv=None, job_progress='', qtgui=None):
return 1 return 1
if args.height > 0: if args.height > 0:
for sourceDir in args.input: for sourceDir in args.input:
targetDir = sourceDir targetDir = sourceDir + "-Splitted"
if os.path.isdir(sourceDir): if os.path.isdir(sourceDir):
rmtree(targetDir, True)
copytree(sourceDir, targetDir)
work = [] work = []
pagenumber = 1 pagenumber = 1
splitWorkerOutput = [] splitWorkerOutput = []
splitWorkerPool = Pool(maxtasksperchild=10) splitWorkerPool = Pool(maxtasksperchild=10)
if args.merge: if args.merge:
print(f"{job_progress}Merging images...") print("Merging images...")
directoryNumer = 1 directoryNumer = 1
mergeWork = [] mergeWork = []
mergeWorkerOutput = [] mergeWorkerOutput = []
@@ -273,7 +239,7 @@ def main(argv=None, job_progress='', qtgui=None):
directoryNumer += 1 directoryNumer += 1
mergeWork.append([os.path.join(root, directory)]) mergeWork.append([os.path.join(root, directory)])
if GUI: if GUI:
GUI.progressBarTick.emit(f'{job_progress}Combining images') GUI.progressBarTick.emit('Combining images')
GUI.progressBarTick.emit(str(directoryNumer)) GUI.progressBarTick.emit(str(directoryNumer))
for i in mergeWork: for i in mergeWork:
mergeWorkerPool.apply_async(func=mergeDirectory, args=(i, ), callback=mergeDirectoryTick) mergeWorkerPool.apply_async(func=mergeDirectory, args=(i, ), callback=mergeDirectoryTick)
@@ -286,8 +252,7 @@ def main(argv=None, job_progress='', qtgui=None):
rmtree(targetDir, True) rmtree(targetDir, True)
raise RuntimeError("One of workers crashed. Cause: " + mergeWorkerOutput[0][0], raise RuntimeError("One of workers crashed. Cause: " + mergeWorkerOutput[0][0],
mergeWorkerOutput[0][1]) mergeWorkerOutput[0][1])
print(f"{job_progress}Splitting images...") print("Splitting images...")
dot_clean(targetDir)
for root, _, files in os.walk(targetDir, False): for root, _, files in os.walk(targetDir, False):
for name in files: for name in files:
if getImageFileName(name) is not None: if getImageFileName(name) is not None:
@@ -296,7 +261,7 @@ def main(argv=None, job_progress='', qtgui=None):
else: else:
os.remove(os.path.join(root, name)) os.remove(os.path.join(root, name))
if GUI: if GUI:
GUI.progressBarTick.emit(f'{job_progress}Splitting images') GUI.progressBarTick.emit('Splitting images')
GUI.progressBarTick.emit(str(pagenumber)) GUI.progressBarTick.emit(str(pagenumber))
GUI.progressBarTick.emit('tick') GUI.progressBarTick.emit('tick')
if len(work) > 0: if len(work) > 0:
@@ -304,7 +269,6 @@ def main(argv=None, job_progress='', qtgui=None):
splitWorkerPool.apply_async(func=splitImage, args=(i, ), callback=splitImageTick) splitWorkerPool.apply_async(func=splitImage, args=(i, ), callback=splitImageTick)
splitWorkerPool.close() splitWorkerPool.close()
splitWorkerPool.join() splitWorkerPool.join()
dot_clean(targetDir)
if GUI and not GUI.conversionAlive: if GUI and not GUI.conversionAlive:
rmtree(targetDir, True) rmtree(targetDir, True)
raise UserWarning("Conversion interrupted.") raise UserWarning("Conversion interrupted.")
@@ -312,9 +276,12 @@ def main(argv=None, job_progress='', qtgui=None):
rmtree(targetDir, True) rmtree(targetDir, True)
raise RuntimeError("One of workers crashed. Cause: " + splitWorkerOutput[0][0], raise RuntimeError("One of workers crashed. Cause: " + splitWorkerOutput[0][0],
splitWorkerOutput[0][1]) splitWorkerOutput[0][1])
if args.inPlace:
rmtree(sourceDir)
move(targetDir, sourceDir)
else: else:
rmtree(targetDir, True) rmtree(targetDir, True)
raise UserWarning("C2P: Source directory is empty.") raise UserWarning("Source directory is empty.")
else: else:
raise UserWarning("Provided input is not a directory.") raise UserWarning("Provided input is not a directory.")
else: else:

View File

@@ -18,19 +18,16 @@
# PERFORMANCE OF THIS SOFTWARE. # PERFORMANCE OF THIS SOFTWARE.
# #
from functools import cached_property, lru_cache from functools import cached_property
import os import os
from pathlib import Path
import platform import platform
import distro import distro
from subprocess import STDOUT, PIPE, CalledProcessError from subprocess import STDOUT, PIPE, CalledProcessError
from xml.dom.minidom import parseString from xml.dom.minidom import parseString
from xml.parsers.expat import ExpatError from xml.parsers.expat import ExpatError
from .shared import IMAGE_TYPES, subprocess_run from .shared import subprocess_run
EXTRACTION_ERROR = 'Failed to extract archive. Try extracting file outside of KCC.' EXTRACTION_ERROR = 'Failed to extract archive. Try extracting file outside of KCC.'
SEVENZIP = '7zz' if platform.system() == 'Darwin' else '7z'
TAR = 'bsdtar' if platform.system() == 'Linux' else 'tar'
class ComicArchive: class ComicArchive:
@@ -38,22 +35,21 @@ class ComicArchive:
self.filepath = filepath self.filepath = filepath
if not os.path.isfile(self.filepath): if not os.path.isfile(self.filepath):
raise OSError('File not found.') raise OSError('File not found.')
self.dirname, self.basename = os.path.split(filepath)
@cached_property @cached_property
def type(self): def type(self):
extraction_commands = [ extraction_commands = [
[SEVENZIP, 'l', '-y', '-p1', self.basename], ['7z', 'l', '-y', '-p1', self.filepath],
] ]
if distro.id() == 'fedora' or distro.like() == 'fedora': if distro.id() == 'fedora' or distro.like() == 'fedora':
extraction_commands.append( extraction_commands.append(
['unrar', 'l', '-y', '-p1', self.basename], ['unrar', 'l', '-y', '-p1', self.filepath],
) )
for cmd in extraction_commands: for cmd in extraction_commands:
try: try:
process = subprocess_run(cmd, capture_output=True, check=True, cwd=self.dirname) process = subprocess_run(cmd, capture_output=True, check=True)
for line in process.stdout.splitlines(): for line in process.stdout.splitlines():
if b'Type =' in line: if b'Type =' in line:
return line.rstrip().decode().split(' = ')[1].upper() return line.rstrip().decode().split(' = ')[1].upper()
@@ -67,33 +63,30 @@ class ComicArchive:
def extract(self, targetdir): def extract(self, targetdir):
if not os.path.isdir(targetdir): if not os.path.isdir(targetdir):
raise OSError('Target directory doesn\'t exist.') raise OSError('Target directory doesn\'t exist.')
if Path(self.basename).suffix.lower() in IMAGE_TYPES:
raise UserWarning('Put images into folder and drag and drop folder into KCC window.')
missing = [] missing = []
extraction_commands = [ extraction_commands = [
[TAR, '--exclude', '__MACOSX', '--exclude', '.DS_Store', '--exclude', 'thumbs.db', '--exclude', 'Thumbs.db', '-xf', self.basename, '-C', targetdir], ['tar', '--exclude', '__MACOSX', '--exclude', '.DS_Store', '--exclude', 'thumbs.db', '--exclude', 'Thumbs.db', '-xf', self.filepath, '-C', targetdir],
[SEVENZIP, 'x', '-y', '-xr!__MACOSX', '-xr!.DS_Store', '-xr!thumbs.db', '-xr!Thumbs.db', '-o' + targetdir, self.basename], ['7z', 'x', '-y', '-xr!__MACOSX', '-xr!.DS_Store', '-xr!thumbs.db', '-xr!Thumbs.db', '-o' + targetdir, self.filepath],
] ]
if platform.system() == 'Darwin': if platform.system() == 'Darwin':
extraction_commands.append( extraction_commands.append(
['unar', self.basename, '-D', '-f', '-o', targetdir] ['unar', self.filepath, '-f', '-o', targetdir]
) )
extraction_commands.reverse() extraction_commands.reverse()
if distro.id() == 'fedora' or distro.like() == 'fedora': if distro.id() == 'fedora' or distro.like() == 'fedora':
extraction_commands.append( extraction_commands.append(
['unrar', 'x', '-y', '-x__MACOSX', '-x.DS_Store', '-xthumbs.db', '-xThumbs.db', self.basename, targetdir] ['unrar', 'x', '-y', '-x__MACOSX', '-x.DS_Store', '-xthumbs.db', '-xThumbs.db', self.filepath, targetdir]
) )
for cmd in extraction_commands: for cmd in extraction_commands:
try: try:
subprocess_run(cmd, capture_output=True, check=True, cwd=self.dirname) subprocess_run(cmd, capture_output=True, check=True)
return targetdir return targetdir
except FileNotFoundError: except FileNotFoundError:
missing.append(cmd[0]) missing.append(cmd[0])
except CalledProcessError: except CalledProcessError:
@@ -107,30 +100,17 @@ class ComicArchive:
def addFile(self, sourcefile): def addFile(self, sourcefile):
if self.type in ['RAR', 'RAR5']: if self.type in ['RAR', 'RAR5']:
raise NotImplementedError raise NotImplementedError
process = subprocess_run([SEVENZIP, 'a', '-y', self.basename, sourcefile], process = subprocess_run(['7z', 'a', '-y', self.filepath, sourcefile],
stdout=PIPE, stderr=STDOUT, cwd=self.dirname) stdout=PIPE, stderr=STDOUT)
if process.returncode != 0: if process.returncode != 0:
raise OSError('Failed to add the file.') raise OSError('Failed to add the file.')
def extractMetadata(self): def extractMetadata(self):
process = subprocess_run([SEVENZIP, 'x', '-y', '-so', self.basename, 'ComicInfo.xml'], process = subprocess_run(['7z', 'x', '-y', '-so', self.filepath, 'ComicInfo.xml'],
stdout=PIPE, stderr=STDOUT, cwd=self.dirname) stdout=PIPE, stderr=STDOUT)
if process.returncode != 0: if process.returncode != 0:
raise OSError(EXTRACTION_ERROR) raise OSError(EXTRACTION_ERROR)
try: try:
return parseString(process.stdout) return parseString(process.stdout)
except ExpatError: except ExpatError:
return None return None
@lru_cache
def available_archive_tools():
available = []
for tool in [TAR, SEVENZIP, 'unar', 'unrar']:
try:
subprocess_run([tool], stdout=PIPE, stderr=STDOUT)
available.append(tool)
except (FileNotFoundError, CalledProcessError):
pass
return available

View File

@@ -20,18 +20,13 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import io import io
import os import os
import numpy as np
from pathlib import Path from pathlib import Path
from functools import cached_property
import mozjpeg_lossless_optimization import mozjpeg_lossless_optimization
from PIL import Image, ImageOps, ImageFile, ImageChops, ImageDraw from PIL import Image, ImageOps, ImageStat, ImageChops, ImageFilter
from .rainbow_artifacts_eraser import erase_rainbow_artifacts
from .page_number_crop_alg import get_bbox_crop_margin_page_number, get_bbox_crop_margin from .page_number_crop_alg import get_bbox_crop_margin_page_number, get_bbox_crop_margin
from .inter_panel_crop_alg import crop_empty_inter_panel from .inter_panel_crop_alg import crop_empty_inter_panel
AUTO_CROP_THRESHOLD = 0.015 AUTO_CROP_THRESHOLD = 0.015
ImageFile.LOAD_TRUNCATED_IMAGES = True
class ProfileData: class ProfileData:
@@ -86,28 +81,20 @@ class ProfileData:
] ]
ProfilesKindleEBOK = { ProfilesKindleEBOK = {
'K1': ("Kindle 1", (600, 670), Palette4, 1.8),
'K2': ("Kindle 2", (600, 670), Palette15, 1.8),
'KDX': ("Kindle DX/DXG", (824, 1000), Palette16, 1.8),
'K34': ("Kindle Keyboard/Touch", (600, 800), Palette16, 1.8),
'K578': ("Kindle", (600, 800), Palette16, 1.8),
'KPW': ("Kindle Paperwhite 1/2", (758, 1024), Palette16, 1.8),
'KV': ("Kindle Paperwhite 3/4/Voyage/Oasis", (1072, 1448), Palette16, 1.8),
} }
ProfilesKindlePDOC = { ProfilesKindlePDOC = {
'K1': ("Kindle 1", (600, 670), Palette4, 1.0), 'KO': ("Kindle Oasis 2/3/Paperwhite 12/Colorsoft 12", (1264, 1680), Palette16, 1.8),
'K2': ("Kindle 2", (600, 670), Palette15, 1.0), 'K11': ("Kindle 11", (1072, 1448), Palette16, 1.8),
'KDX': ("Kindle DX/DXG", (824, 1000), Palette16, 1.0), 'KPW5': ("Kindle Paperwhite 5/Signature Edition", (1236, 1648), Palette16, 1.8),
'K34': ("Kindle Keyboard/Touch", (600, 800), Palette16, 1.0), 'KS': ("Kindle Scribe", (1860, 2480), Palette16, 1.8),
'K57': ("Kindle 5/7", (600, 800), Palette16, 1.0),
'KPW': ("Kindle Paperwhite 1/2", (758, 1024), Palette16, 1.0),
'KV': ("Kindle Voyage", (1072, 1448), Palette16, 1.0),
'KPW34': ("Kindle Paperwhite 3/4/Oasis", (1072, 1448), Palette16, 1.0),
'K810': ("Kindle 8/10", (600, 800), Palette16, 1.0),
'KO': ("Kindle Oasis 2/3/Paperwhite 12", (1264, 1680), Palette16, 1.0),
'K11': ("Kindle 11", (1072, 1448), Palette16, 1.0),
'KPW5': ("Kindle Paperwhite 5/Signature Edition", (1236, 1648), Palette16, 1.0),
'KS1860': ("Kindle 1860", (1860, 1920), Palette16, 1.0),
'KS1920': ("Kindle 1920", (1920, 1920), Palette16, 1.0),
'KS1240': ("Kindle 1240", (1240, 1860), Palette16, 1.0),
'KS': ("Kindle Scribe 1/2", (1860, 2480), Palette16, 1.0),
'KCS': ("Kindle Colorsoft", (1264, 1680), Palette16, 1.0),
'KS3': ("Kindle Scribe 3", (1986, 2648), Palette16, 1.0),
'KSCS': ("Kindle Scribe Colorsoft", (1986, 2648), Palette16, 1.0),
} }
ProfilesKindle = { ProfilesKindle = {
@@ -116,35 +103,34 @@ class ProfileData:
} }
ProfilesKobo = { ProfilesKobo = {
'KoMT': ("Kobo Mini/Touch", (600, 800), Palette16, 1.0), 'KoMT': ("Kobo Mini/Touch", (600, 800), Palette16, 1.8),
'KoG': ("Kobo Glo", (768, 1024), Palette16, 1.0), 'KoG': ("Kobo Glo", (768, 1024), Palette16, 1.8),
'KoGHD': ("Kobo Glo HD", (1072, 1448), Palette16, 1.0), 'KoGHD': ("Kobo Glo HD", (1072, 1448), Palette16, 1.8),
'KoA': ("Kobo Aura", (758, 1024), Palette16, 1.0), 'KoA': ("Kobo Aura", (758, 1024), Palette16, 1.8),
'KoAHD': ("Kobo Aura HD", (1080, 1440), Palette16, 1.0), 'KoAHD': ("Kobo Aura HD", (1080, 1440), Palette16, 1.8),
'KoAH2O': ("Kobo Aura H2O", (1080, 1430), Palette16, 1.0), 'KoAH2O': ("Kobo Aura H2O", (1080, 1430), Palette16, 1.8),
'KoAO': ("Kobo Aura ONE", (1404, 1872), Palette16, 1.0), 'KoAO': ("Kobo Aura ONE", (1404, 1872), Palette16, 1.8),
'KoN': ("Kobo Nia", (758, 1024), Palette16, 1.0), 'KoN': ("Kobo Nia", (758, 1024), Palette16, 1.8),
'KoC': ("Kobo Clara HD/Kobo Clara 2E", (1072, 1448), Palette16, 1.0), 'KoC': ("Kobo Clara HD/Kobo Clara 2E", (1072, 1448), Palette16, 1.8),
'KoCC': ("Kobo Clara Colour", (1072, 1448), Palette16, 1.0), 'KoCC': ("Kobo Clara Colour", (1072, 1448), Palette16, 1.8),
'KoL': ("Kobo Libra H2O/Kobo Libra 2", (1264, 1680), Palette16, 1.0), 'KoL': ("Kobo Libra H2O/Kobo Libra 2", (1264, 1680), Palette16, 1.8),
'KoLC': ("Kobo Libra Colour", (1264, 1680), Palette16, 1.0), 'KoLC': ("Kobo Libra Colour", (1264, 1680), Palette16, 1.8),
'KoF': ("Kobo Forma", (1440, 1920), Palette16, 1.0), 'KoF': ("Kobo Forma", (1440, 1920), Palette16, 1.8),
'KoS': ("Kobo Sage", (1440, 1920), Palette16, 1.0), 'KoS': ("Kobo Sage", (1440, 1920), Palette16, 1.8),
'KoE': ("Kobo Elipsa", (1404, 1872), Palette16, 1.0), 'KoE': ("Kobo Elipsa", (1404, 1872), Palette16, 1.8),
} }
ProfilesRemarkable = { ProfilesRemarkable = {
'Rmk1': ("reMarkable 1", (1404, 1872), Palette16, 1.0), 'Rmk1': ("reMarkable 1", (1404, 1872), Palette16, 1.8),
'Rmk2': ("reMarkable 2", (1404, 1872), Palette16, 1.0), 'Rmk2': ("reMarkable 2", (1404, 1872), Palette16, 1.8),
'RmkPP': ("reMarkable Paper Pro", (1620, 2160), Palette16, 1.0), 'RmkPP': ("reMarkable Paper Pro", (1620, 2160), Palette16, 1.8),
'RmkPPMove': ("reMarkable Paper Pro Move", (954, 1696), Palette16, 1.0),
} }
Profiles = { Profiles = {
**ProfilesKindle, **ProfilesKindle,
**ProfilesKobo, **ProfilesKobo,
**ProfilesRemarkable, **ProfilesRemarkable,
'OTHER': ("Other", (0, 0), Palette16, 1.0), 'OTHER': ("Other", (0, 0), Palette16, 1.8),
} }
@@ -158,10 +144,11 @@ class ComicPageParser:
# Detect corruption in source image, let caller catch any exceptions triggered. # Detect corruption in source image, let caller catch any exceptions triggered.
srcImgPath = os.path.join(source[0], source[1]) srcImgPath = os.path.join(source[0], source[1])
# Image.open(srcImgPath).verify() self.image = Image.open(srcImgPath)
with Image.open(srcImgPath) as im: self.image.verify()
self.image = im.copy() self.image = Image.open(srcImgPath).convert('RGB')
self.color = self.colorCheck()
self.fill = self.fillCheck() self.fill = self.fillCheck()
# backwards compatibility for Pillow >9.1.0 # backwards compatibility for Pillow >9.1.0
if not hasattr(Image, 'Resampling'): if not hasattr(Image, 'Resampling'):
@@ -192,16 +179,13 @@ class ComicPageParser:
new_image = Image.new("RGB", (int(width / 2), int(height*2))) new_image = Image.new("RGB", (int(width / 2), int(height*2)))
new_image.paste(pageone, (0, 0)) new_image.paste(pageone, (0, 0))
new_image.paste(pagetwo, (0, height)) new_image.paste(pagetwo, (0, height))
self.payload.append(['N', self.source, new_image, self.fill]) self.payload.append(['N', self.source, new_image, self.color, self.fill])
elif (width > height) != (dstwidth > dstheight) and width <= dstheight and height <= dstwidth \ elif (width > height) != (dstwidth > dstheight) and width <= dstheight and height <= dstwidth \
and not self.opt.webtoon and self.opt.splitter == 1: and not self.opt.webtoon and self.opt.splitter == 1:
spread = self.image spread = self.image
if not self.opt.norotate: if not self.opt.norotate:
if not self.opt.rotateright: spread = spread.rotate(90, Image.Resampling.BICUBIC, True)
spread = spread.rotate(90, Image.Resampling.BICUBIC, True) self.payload.append(['R', self.source, spread, self.color, self.fill])
else:
spread = spread.rotate(-90, Image.Resampling.BICUBIC, True)
self.payload.append(['R', self.source, spread, self.fill])
elif (width > height) != (dstwidth > dstheight) and not self.opt.webtoon: elif (width > height) != (dstwidth > dstheight) and not self.opt.webtoon:
if self.opt.splitter != 1: if self.opt.splitter != 1:
if width > height: if width > height:
@@ -216,18 +200,38 @@ class ComicPageParser:
else: else:
pageone = self.image.crop(leftbox) pageone = self.image.crop(leftbox)
pagetwo = self.image.crop(rightbox) pagetwo = self.image.crop(rightbox)
self.payload.append(['S1', self.source, pageone, self.fill]) self.payload.append(['S1', self.source, pageone, self.color, self.fill])
self.payload.append(['S2', self.source, pagetwo, self.fill]) self.payload.append(['S2', self.source, pagetwo, self.color, self.fill])
if self.opt.splitter > 0: if self.opt.splitter > 0:
spread = self.image spread = self.image
if not self.opt.norotate: if not self.opt.norotate:
if not self.opt.rotateright: spread = spread.rotate(90, Image.Resampling.BICUBIC, True)
spread = spread.rotate(90, Image.Resampling.BICUBIC, True) self.payload.append(['R', self.source, spread,
else: self.color, self.fill])
spread = spread.rotate(-90, Image.Resampling.BICUBIC, True)
self.payload.append(['R', self.source, spread, self.fill])
else: else:
self.payload.append(['N', self.source, self.image, self.fill]) self.payload.append(['N', self.source, self.image, self.color, self.fill])
def colorCheck(self):
if self.opt.webtoon:
return True
else:
img = self.image.copy()
bands = img.getbands()
if bands == ('R', 'G', 'B') or bands == ('R', 'G', 'B', 'A'):
thumb = img.resize((40, 40))
SSE, bias = 0, [0, 0, 0]
bias = ImageStat.Stat(thumb).mean[:3]
bias = [b - sum(bias) / 3 for b in bias]
for pixel in thumb.getdata():
mu = sum(pixel) / 3
SSE += sum((pixel[i] - mu - bias[i]) * (pixel[i] - mu - bias[i]) for i in [0, 1, 2])
MSE = float(SSE) / (40 * 40)
if MSE > 22:
return True
else:
return False
else:
return False
def fillCheck(self): def fillCheck(self):
if self.opt.bordersColor: if self.opt.bordersColor:
@@ -269,244 +273,95 @@ class ComicPageParser:
class ComicPage: class ComicPage:
def __init__(self, options, mode, path, image, fill): def __init__(self, options, mode, path, image, color, fill):
self.opt = options self.opt = options
_, self.size, self.palette, self.gamma = self.opt.profileData _, self.size, self.palette, self.gamma = self.opt.profileData
if self.opt.hq: if self.opt.hq:
self.size = (int(self.size[0] * 1.5), int(self.size[1] * 1.5)) self.size = (int(self.size[0] * 1.5), int(self.size[1] * 1.5))
self.original_color_mode = image.mode self.kindle_scribe_azw3 = (options.profile == 'KS') and (options.format in ('MOBI', 'EPUB'))
# TODO: color check earlier self.image = image
self.image = image.convert("RGB") self.color = color
self.color = self.colorCheck()
self.colorOutput = self.color and self.opt.forcecolor
self.fill = fill self.fill = fill
self.rotated = False self.rotated = False
self.orgPath = os.path.join(path[0], path[1]) self.orgPath = os.path.join(path[0], path[1])
self.targetPathStart = os.path.join(path[0], os.path.splitext(path[1])[0])
if 'N' in mode: if 'N' in mode:
self.targetPathOrder = '-kcc-x' self.targetPath = os.path.join(path[0], os.path.splitext(path[1])[0]) + '-kcc'
elif 'R' in mode: elif 'R' in mode:
self.targetPathOrder = '-kcc-a' if options.rotatefirst else '-kcc-d' self.targetPath = os.path.join(path[0], os.path.splitext(path[1])[0]) + '-kcc-a'
if not options.norotate: if not options.norotate:
self.rotated = True self.rotated = True
elif 'S1' in mode: elif 'S1' in mode:
self.targetPathOrder = '-kcc-b' self.targetPath = os.path.join(path[0], os.path.splitext(path[1])[0]) + '-kcc-b'
elif 'S2' in mode: elif 'S2' in mode:
self.targetPathOrder = '-kcc-c' self.targetPath = os.path.join(path[0], os.path.splitext(path[1])[0]) + '-kcc-c'
# backwards compatibility for Pillow >9.1.0 # backwards compatibility for Pillow >9.1.0
if not hasattr(Image, 'Resampling'): if not hasattr(Image, 'Resampling'):
Image.Resampling = Image Image.Resampling = Image
def colorCheck(self):
if self.original_color_mode in ("L", "1"):
return False
if self.opt.webtoon:
return True
if self.calculate_color():
return True
return False
# cut off pixels from both ends of the histogram to remove jpg compression artifacts
# for better accuracy, you could split the image in half and analyze each half separately
def histograms_cutoff(self, cb_hist, cr_hist, cutoff=(2, 2)):
if cutoff == (0, 0):
return cb_hist, cr_hist
for h in cb_hist, cr_hist:
# get number of pixels
n = sum(h)
# remove cutoff% pixels from the low end
cut = int(n * cutoff[0] // 100)
for lo in range(256):
if cut > h[lo]:
cut = cut - h[lo]
h[lo] = 0
else:
h[lo] -= cut
cut = 0
if cut <= 0:
break
# remove cutoff% samples from the high end
cut = int(n * cutoff[1] // 100)
for hi in range(255, -1, -1):
if cut > h[hi]:
cut = cut - h[hi]
h[hi] = 0
else:
h[hi] -= cut
cut = 0
if cut <= 0:
break
return cb_hist, cr_hist
def color_precision(self, cb_hist_original, cr_hist_original, cutoff, diff_threshold):
cb_hist, cr_hist = self.histograms_cutoff(cb_hist_original.copy(), cr_hist_original.copy(), cutoff)
cb_nonzero = [i for i, e in enumerate(cb_hist) if e]
cr_nonzero = [i for i, e in enumerate(cr_hist) if e]
cb_spread = cb_nonzero[-1] - cb_nonzero[0]
cr_spread = cr_nonzero[-1] - cr_nonzero[0]
# bias adjustment, don't go lower than 7
SPREAD_THRESHOLD = 7
if self.opt.forcecolor:
if any([
cb_nonzero[0] > 128,
cr_nonzero[0] > 128,
cb_nonzero[-1] < 128,
cr_nonzero[-1] < 128,
]):
return True, True
elif cb_spread < SPREAD_THRESHOLD and cr_spread < SPREAD_THRESHOLD:
return True, False
DIFF_THRESHOLD = diff_threshold
if any([
cb_nonzero[0] <= 128 - DIFF_THRESHOLD,
cr_nonzero[0] <= 128 - DIFF_THRESHOLD,
cb_nonzero[-1] >= 128 + DIFF_THRESHOLD,
cr_nonzero[-1] >= 128 + DIFF_THRESHOLD,
]):
return True, True
return False, None
def calculate_color(self):
img = self.image.convert("YCbCr")
_, cb, cr = img.split()
cb_hist_original = cb.histogram()
cr_hist_original = cr.histogram()
# you can increase 22 but don't increase 10. 4 maybe can go higher
for cutoff, diff_threshold in [((0, 0), 22), ((.2, .2), 10), ((3, 3), 4)]:
done, decision = self.color_precision(cb_hist_original, cr_hist_original, cutoff, diff_threshold)
if done:
return decision
return False
def saveToDir(self): def saveToDir(self):
try: try:
flags = [] flags = []
if not self.opt.forcecolor and not self.opt.forcepng:
self.image = self.image.convert('L')
if self.rotated: if self.rotated:
flags.append('Rotated') flags.append('Rotated')
if self.fill != 'white': if self.fill != 'white':
flags.append('BlackBackground') flags.append('BlackBackground')
if self.opt.kindle_scribe_azw3 and self.image.size[1] > 1920: if self.opt.forcepng:
w, h = self.image.size self.image.info["transparency"] = None
targetPath = self.save_with_codec(self.image.crop((0, 0, w, 1920)), self.targetPathStart + self.targetPathOrder + '-above') self.targetPath += '.png'
self.save_with_codec(self.image.crop((0, 1920, w, h)), self.targetPathStart + self.targetPathOrder + '-below') self.image.save(self.targetPath, 'PNG', optimize=1)
elif self.opt.kindle_scribe_azw3:
targetPath = self.save_with_codec(self.image, self.targetPathStart + self.targetPathOrder + '-whole')
else: else:
targetPath = self.save_with_codec(self.image, self.targetPathStart + self.targetPathOrder) self.targetPath += '.jpg'
if self.opt.mozjpeg:
with io.BytesIO() as output:
self.image.save(output, format="JPEG", optimize=1, quality=85)
input_jpeg_bytes = output.getvalue()
output_jpeg_bytes = mozjpeg_lossless_optimization.optimize(input_jpeg_bytes)
with open(self.targetPath, "wb") as output_jpeg_file:
output_jpeg_file.write(output_jpeg_bytes)
else:
self.image.save(self.targetPath, 'JPEG', optimize=1, quality=85)
if os.path.isfile(self.orgPath): if os.path.isfile(self.orgPath):
os.remove(self.orgPath) os.remove(self.orgPath)
return [Path(targetPath).name, flags] return [Path(self.targetPath).name, flags]
except IOError as err: except IOError as err:
raise RuntimeError('Cannot save image. ' + str(err)) raise RuntimeError('Cannot save image. ' + str(err))
def save_with_codec(self, image, targetPath): def autocontrastImage(self):
if self.opt.forcepng and (not self.colorOutput or self.opt.force_png_rgb):
image.info.pop('transparency', None)
if self.opt.webp_output:
targetPath += '.webp'
image.save(targetPath, 'WEBP', lossless=True, quality=self.opt.jpegquality)
elif self.opt.kindle_azw3:
targetPath += '.gif'
image.save(targetPath, 'GIF', optimize=1, interlace=False)
else:
targetPath += '.png'
image.save(targetPath, 'PNG', optimize=1)
else:
if self.opt.webp_output:
targetPath += '.webp'
image.save(targetPath, 'WEBP', quality=self.opt.jpegquality)
elif self.opt.mozjpeg:
targetPath += '.jpg'
with io.BytesIO() as output:
image.save(output, format="JPEG", optimize=1, quality=self.opt.jpegquality)
input_jpeg_bytes = output.getvalue()
output_jpeg_bytes = mozjpeg_lossless_optimization.optimize(input_jpeg_bytes)
with open(targetPath, "wb") as output_jpeg_file:
output_jpeg_file.write(output_jpeg_bytes)
else:
targetPath += '.jpg'
image.save(targetPath, 'JPEG', optimize=1, quality=self.opt.jpegquality)
return targetPath
def gammaCorrectImage(self):
gamma = self.opt.gamma gamma = self.opt.gamma
if gamma < 0.1: if gamma < 0.1:
gamma = self.gamma gamma = self.gamma
if self.gamma != 1.0 and self.color: if self.gamma != 1.0 and self.color:
gamma = 1.0 gamma = 1.0
if gamma == 1.0: if gamma == 1.0:
pass self.image = ImageOps.autocontrast(self.image)
else: else:
self.image = Image.eval(self.image, lambda a: int(255 * (a / 255.) ** gamma)) self.image = ImageOps.autocontrast(Image.eval(self.image, lambda a: int(255 * (a / 255.) ** gamma)))
def autocontrastImage(self):
if self.opt.webtoon:
return
if self.opt.noautocontrast:
return
if self.color and not self.opt.colorautocontrast:
return
# if image is extremely low contrast, that was probably intentional
extrema = self.image.convert('L').getextrema()
if extrema[1] - extrema[0] < (255 - 32 * 3):
return
if self.opt.autolevel:
self.autolevelImage()
self.image = ImageOps.autocontrast(self.image, preserve_tone=True)
def autolevelImage(self):
img = self.image
if self.color:
img = self.image.convert("YCbCr")
y, cb, cr = img.split()
img = y
else:
img = img.convert('L')
h = img.histogram()
most_common_dark_pixel_count = max(h[:64])
black_point = h.index(most_common_dark_pixel_count)
bp = black_point
img = img.point(lambda p: p if p > bp else bp)
if self.color:
self.image = Image.merge(mode='YCbCr', bands=[img, cb, cr]).convert('RGB')
else:
self.image = img
def convertToGrayscale(self):
self.image = self.image.convert('L')
def quantizeImage(self): def quantizeImage(self):
# remove all color pixels from image, since colorCheck() has some tolerance colors = len(self.palette) // 3
# quantize with a small number of color pixels in a mostly b/w image can have unexpected results if colors < 256:
self.image = self.image.convert("RGB") self.palette += self.palette[:3] * (256 - colors)
palImg = Image.new('P', (1, 1)) palImg = Image.new('P', (1, 1))
palImg.putpalette(self.palette) palImg.putpalette(self.palette)
self.image = self.image.convert('L')
self.image = self.image.convert('RGB')
# Quantize is deprecated but new function call it internally anyway...
self.image = self.image.quantize(palette=palImg) self.image = self.image.quantize(palette=palImg)
def optimizeForDisplay(self, eraserainbow, is_color): def optimizeForDisplay(self, reducerainbow):
# Erase rainbow artifacts for grayscale and color images by removing spectral frequencies that cause Moire interference with color filter array # Reduce rainbow artifacts for grayscale images by breaking up dither patterns that cause Moire interference with color filter array
if eraserainbow and all(dim > 1 for dim in self.image.size): if reducerainbow and not self.color:
self.image = erase_rainbow_artifacts(self.image, is_color) unsharpFilter = ImageFilter.UnsharpMask(radius=1, percent=100)
self.image = self.image.filter(unsharpFilter)
self.image = self.image.filter(ImageFilter.BoxBlur(1.0))
self.image = self.image.filter(unsharpFilter)
def resizeImage(self): def resizeImage(self):
if self.opt.norotate and self.targetPathOrder in ('-kcc-a', '-kcc-d') and not self.opt.kindle_scribe_azw3: # kindle scribe conversion to mobi is limited in resolution by kindlegen, same with send to kindle and epub
# TODO: Kindle Scribe case if self.kindle_scribe_azw3:
if self.opt.kindle_azw3 and any(dim > 1920 for dim in self.image.size): self.size = (1440, 1920)
self.image = ImageOps.contain(self.image, (1920, 1920), Image.Resampling.LANCZOS)
elif self.image.size[0] > self.size[0] * 2 or self.image.size[1] > self.size[1]:
self.image = ImageOps.contain(self.image, (self.size[0] * 2, self.size[1]), Image.Resampling.LANCZOS)
return
ratio_device = float(self.size[1]) / float(self.size[0]) ratio_device = float(self.size[1]) / float(self.size[0])
ratio_image = float(self.image.size[1]) / float(self.image.size[0]) ratio_image = float(self.image.size[1]) / float(self.image.size[0])
method = self.resize_method() method = self.resize_method()
@@ -515,17 +370,17 @@ class ComicPage:
elif method == Image.Resampling.BICUBIC and not self.opt.upscale: elif method == Image.Resampling.BICUBIC and not self.opt.upscale:
pass pass
else: # if image bigger than device resolution or smaller with upscaling else: # if image bigger than device resolution or smaller with upscaling
if self.opt.profile == 'KDX' and abs(ratio_image - ratio_device) < AUTO_CROP_THRESHOLD * 3: if abs(ratio_image - ratio_device) < AUTO_CROP_THRESHOLD:
self.image = ImageOps.fit(self.image, self.size, method=method) self.image = ImageOps.fit(self.image, self.size, method=method)
elif abs(ratio_image - ratio_device) < AUTO_CROP_THRESHOLD: elif (self.opt.format == 'CBZ' or self.opt.kfx) and not self.opt.white_borders:
self.image = ImageOps.fit(self.image, self.size, method=method)
elif (self.opt.format in ('CBZ', 'PDF') or self.opt.kfx) and not self.opt.white_borders:
self.image = ImageOps.pad(self.image, self.size, method=method, color=self.fill) self.image = ImageOps.pad(self.image, self.size, method=method, color=self.fill)
else: else:
if self.kindle_scribe_azw3:
self.size = (1860, 1920)
self.image = ImageOps.contain(self.image, self.size, method=method) self.image = ImageOps.contain(self.image, self.size, method=method)
def resize_method(self): def resize_method(self):
if self.image.size[0] <= self.size[0] and self.image.size[1] <= self.size[1]: if self.image.size[0] < self.size[0] and self.image.size[1] < self.size[1]:
return Image.Resampling.BICUBIC return Image.Resampling.BICUBIC
else: else:
return Image.Resampling.LANCZOS return Image.Resampling.LANCZOS
@@ -545,29 +400,26 @@ class ComicPage:
bbox = get_bbox_crop_margin_page_number(self.image, power, self.fill) bbox = get_bbox_crop_margin_page_number(self.image, power, self.fill)
if bbox: if bbox:
w, h = self.image.size
left, upper, right, lower = bbox
# don't crop more than 10% of image
bbox = (min(0.1*w, left), min(0.1*h, upper), max(0.9*w, right), max(0.9*h, lower))
self.maybeCrop(bbox, minimum) self.maybeCrop(bbox, minimum)
def cropMargin(self, power, minimum): def cropMargin(self, power, minimum):
bbox = get_bbox_crop_margin(self.image, power, self.fill) bbox = get_bbox_crop_margin(self.image, power, self.fill)
if bbox: if bbox:
w, h = self.image.size
left, upper, right, lower = bbox
# don't crop more than 10% of image
bbox = (min(0.1*w, left), min(0.1*h, upper), max(0.9*w, right), max(0.9*h, lower))
self.maybeCrop(bbox, minimum) self.maybeCrop(bbox, minimum)
def cropInterPanelEmptySections(self, direction): def cropInterPanelEmptySections(self, direction):
self.image = crop_empty_inter_panel(self.image, direction, background_color=self.fill) self.image = crop_empty_inter_panel(self.image, direction, background_color=self.fill)
class Cover: class Cover:
def __init__(self, source, opt): def __init__(self, source, target, opt, tomeid):
self.options = opt self.options = opt
self.source = source self.source = source
self.target = target
if tomeid == 0:
self.tomeid = 1
else:
self.tomeid = tomeid
self.image = Image.open(source) self.image = Image.open(source)
# backwards compatibility for Pillow >9.1.0 # backwards compatibility for Pillow >9.1.0
if not hasattr(Image, 'Resampling'): if not hasattr(Image, 'Resampling'):
@@ -576,60 +428,33 @@ class Cover:
def process(self): def process(self):
self.image = self.image.convert('RGB') self.image = self.image.convert('RGB')
self.image = ImageOps.autocontrast(self.image, preserve_tone=True) self.image = ImageOps.autocontrast(self.image)
if not self.options.forcecolor: if not self.options.forcecolor:
self.image = self.image.convert('L') self.image = self.image.convert('L')
if self.options.smartcovercrop:
self.crop_main_cover()
size = list(self.options.profileData[1])
if self.options.kindle_scribe_azw3:
size[0] = min(size[0], 1920)
size[1] = min(size[1], 1920)
if self.options.coverfill and not self.options.kindle_scribe_azw3:
# TODO: Kindle Scribe case
self.image = ImageOps.fit(self.image, tuple(size), Image.Resampling.LANCZOS, centering=(0.5, 0.5))
else:
self.image.thumbnail(tuple(size), Image.Resampling.LANCZOS)
def crop_main_cover(self):
w, h = self.image.size w, h = self.image.size
if w / h > 2: if w / h > 2:
if self.options.righttoleft: if self.options.righttoleft:
self.image = self.image.crop((w/6, 0, w/2 - w * 0.02, h)) self.image = self.image.crop((w/6, 0, w/2 - w * 0.02, h))
else: else:
self.image = self.image.crop((w/2 + w * 0.02, 0, 5/6 * w, h)) self.image = self.image.crop((w/2 + w * 0.02, 0, 5/6 * w, h))
elif w / h > 1.34: elif w / h > 1.3:
if self.options.righttoleft: if self.options.righttoleft:
self.image = self.image.crop((0, 0, w/2 - w * 0.03, h)) self.image = self.image.crop((0, 0, w/2 - w * 0.03, h))
else: else:
self.image = self.image.crop((w/2 + w * 0.03, 0, w, h)) self.image = self.image.crop((w/2 + w * 0.03, 0, w, h))
self.image.thumbnail(self.options.profileData[1], Image.Resampling.LANCZOS)
self.save()
def save_to_epub(self, target, tomeid, len_tomes=0): def save(self):
try: try:
if tomeid == 0: self.image.save(self.target, "JPEG", optimize=1, quality=85)
self.image.save(target, "JPEG", optimize=1, quality=self.options.jpegquality)
else:
copy = self.image.copy()
draw = ImageDraw.Draw(copy)
w, h = copy.size
draw.text(
xy=(w/2, h * .85),
text=f'{tomeid}/{len_tomes}',
anchor='ms',
font_size=h//7,
fill=255,
stroke_fill=0,
stroke_width=25
)
copy.save(target, "JPEG", optimize=1, quality=self.options.jpegquality)
except IOError: except IOError:
raise RuntimeError('Failed to save cover.') raise RuntimeError('Failed to save cover.')
def saveToKindle(self, kindle, asin): def saveToKindle(self, kindle, asin):
self.image = ImageOps.contain(self.image, (300, 470), Image.Resampling.LANCZOS) self.image = self.image.resize((300, 470), Image.Resampling.LANCZOS)
try: try:
self.image.save(os.path.join(kindle.path.split('documents')[0], 'system', 'thumbnails', self.image.save(os.path.join(kindle.path.split('documents')[0], 'system', 'thumbnails',
'thumbnail_' + asin + '_EBOK_portrait.jpg'), 'JPEG', optimize=1, quality=self.options.jpegquality) 'thumbnail_' + asin + '_EBOK_portrait.jpg'), 'JPEG', optimize=1, quality=85)
except IOError: except IOError:
raise RuntimeError('Failed to upload cover.') raise RuntimeError('Failed to upload cover.')

View File

@@ -1,10 +1,8 @@
from PIL import Image, ImageFilter, ImageOps, ImageFile from PIL import Image, ImageFilter, ImageOps
import numpy as np import numpy as np
from typing import Literal from typing import Literal
from .common_crop import threshold_from_power, group_close_values from .common_crop import threshold_from_power, group_close_values
ImageFile.LOAD_TRUNCATED_IMAGES = True
''' '''
Crops inter-panel empty spaces (ignores empty spaces near borders - for that use crop margins). Crops inter-panel empty spaces (ignores empty spaces near borders - for that use crop margins).
@@ -21,10 +19,10 @@ def crop_empty_inter_panel(img, direction: Literal["horizontal", "vertical", "bo
img_temp = img img_temp = img
if img.mode != 'L': if img.mode != 'L':
img_temp = ImageOps.grayscale(img_temp) img_temp = ImageOps.grayscale(img)
if background_color != 'white': if background_color != 'white':
img_temp = ImageOps.invert(img_temp) img_temp = ImageOps.invert(img)
img_mat = np.array(img) img_mat = np.array(img)

View File

@@ -123,4 +123,4 @@ class MetadataParser:
cbx.addFile(tmpXML) cbx.addFile(tmpXML)
except OSError as e: except OSError as e:
raise UserWarning(e) raise UserWarning(e)
rmtree(workdir, True) rmtree(workdir)

View File

@@ -1,9 +1,7 @@
from PIL import ImageOps, ImageFilter, ImageFile from PIL import ImageOps, ImageFilter
import numpy as np import numpy as np
from .common_crop import threshold_from_power, group_close_values from .common_crop import threshold_from_power, group_close_values
ImageFile.LOAD_TRUNCATED_IMAGES = True
''' '''
Some assupmptions on the page number sizes Some assupmptions on the page number sizes
@@ -54,7 +52,6 @@ def get_bbox_crop_margin_page_number(img, power=1, background_color='white'):
''' '''
threshold = threshold_from_power(power) threshold = threshold_from_power(power)
bw_img = img.point(lambda p: 255 if p <= threshold else 0) bw_img = img.point(lambda p: 255 if p <= threshold else 0)
ignore_pixels_near_edge(bw_img)
bw_bbox = bw_img.getbbox() bw_bbox = bw_img.getbbox()
if not bw_bbox: # bbox cannot be found in case that the entire resulted image is black. if not bw_bbox: # bbox cannot be found in case that the entire resulted image is black.
return None return None
@@ -144,28 +141,9 @@ def get_bbox_crop_margin(img, power=1, background_color='white'):
''' '''
threshold = threshold_from_power(power) threshold = threshold_from_power(power)
bw_img = img.point(lambda p: 255 if p <= threshold else 0) bw_img = img.point(lambda p: 255 if p <= threshold else 0)
ignore_pixels_near_edge(bw_img)
return bw_img.getbbox() return bw_img.getbbox()
def ignore_pixels_near_edge(bw_img):
w, h = bw_img.size
edge_bbox = [
(0, 0, w, int(0.02 * h)),
(0, int(0.98 * h), w, h),
(0, 0, int(0.02 * w), h),
(int(0.98 * w), 0, w, h)
]
for box in edge_bbox:
edge = bw_img.crop(box)
h = edge.histogram()
if not edge.height or not edge.width:
continue
imperfections = h[255] / (edge.height * edge.width)
if imperfections > 0 and imperfections < .02:
bw_img.paste(im=0, box=box)
def box_intersect(box1, box2, max_dist): def box_intersect(box1, box2, max_dist):
return not (box2[0]-max_dist[0] > box1[1] return not (box2[0]-max_dist[0] > box1[1]

View File

@@ -22,6 +22,8 @@
# #
import os import os
from random import choice
from string import ascii_uppercase, digits
# skip stray images a few pixels in size in some PDFs # skip stray images a few pixels in size in some PDFs
# typical images are many thousands in length # typical images are many thousands in length
@@ -30,9 +32,10 @@ STRAY_IMAGE_LENGTH_THRESHOLD = 300
class PdfJpgExtract: class PdfJpgExtract:
def __init__(self, fname, fullPath): def __init__(self, fname):
self.fname = fname self.fname = fname
self.path = fullPath self.filename = os.path.splitext(fname)
self.path = self.filename[0] + "-KCC-" + ''.join(choice(ascii_uppercase + digits) for _ in range(3))
def getPath(self): def getPath(self):
return self.path return self.path
@@ -45,6 +48,7 @@ class PdfJpgExtract:
endfix = 2 endfix = 2
i = 0 i = 0
njpg = 0 njpg = 0
os.makedirs(self.path)
while True: while True:
istream = pdf.find(b"stream", i) istream = pdf.find(b"stream", i)
if istream < 0: if istream < 0:
@@ -67,9 +71,9 @@ class PdfJpgExtract:
continue continue
jpg = pdf[istart:iend] jpg = pdf[istart:iend]
jpgfile = open(os.path.join(self.path, "jpg%d.jpg" % njpg), "wb") jpgfile = open(self.path + "/jpg%d.jpg" % njpg, "wb")
jpgfile.write(jpg) jpgfile.write(jpg)
jpgfile.close() jpgfile.close()
njpg += 1 njpg += 1
return njpg return self.path, njpg

View File

@@ -1,249 +0,0 @@
import numpy as np
from PIL import Image, ImageFile
ImageFile.LOAD_TRUNCATED_IMAGES = True
def fourier_transform_image(img):
"""
Memory-optimized version that modifies the array in place when possible.
"""
# Convert with minimal copy
img_array = np.asarray(img, dtype=np.float32)
# Use rfft2 if the image is real to save memory
# and computation time (approximately 2x faster)
fft_result = np.fft.rfft2(img_array)
return fft_result
def attenuate_diagonal_frequencies(fft_spectrum, freq_threshold=0.30, target_angle=135,
angle_tolerance=10, attenuation_factor=0.10):
"""
Attenuates specific frequencies in the Fourier domain (optimized version for rfft2).
Args:
fft_spectrum: Result of 2D real Fourier transform (from rfft2)
freq_threshold: Frequency threshold in cycles/pixel (default: 0.3, theoretical max: 0.5)
target_angle: Target angle in degrees (default: 135)
angle_tolerance: Angular tolerance in degrees (default: 15)
attenuation_factor: Attenuation factor (0.1 = 90% attenuation)
Returns:
np.ndarray: Modified FFT with applied attenuation (same format as input)
"""
# Get dimensions of the rfft2 result
if fft_spectrum.ndim == 2:
height, width_rfft = fft_spectrum.shape
else: # 3D array (color channels)
height, width_rfft = fft_spectrum.shape[:2]
# For rfft2, the original width is (width_rfft - 1) * 2
width_original = (width_rfft - 1) * 2
# Create frequency grids for rfft2 format
freq_y = np.fft.fftfreq(height, d=1.0)
freq_x = np.fft.rfftfreq(width_original, d=1.0) # Use rfftfreq for the X dimension
# Use broadcasting to create grids without meshgrid (more efficient)
freq_y_grid = freq_y.reshape(-1, 1) # Column
freq_x_grid = freq_x.reshape(1, -1) # Row
# Calculate squared radial frequencies (avoid sqrt)
freq_radial_sq = freq_x_grid**2 + freq_y_grid**2
freq_threshold_sq = freq_threshold**2
# Frequency condition
freq_condition = freq_radial_sq >= freq_threshold_sq
# Early exit if no frequency satisfies the condition
if not np.any(freq_condition):
return fft_spectrum
# Calculate angles only where necessary
# Use atan2 directly with broadcasting
angles_rad = np.arctan2(freq_y_grid, freq_x_grid)
# Convert to degrees and normalize in a single operation
angles_deg = np.rad2deg(angles_rad) % 360
# Calculation of complementary angle
target_angle_2 = (target_angle + 180) % 360
# Calulation of perpendicular angles (135° + 45° to maximize compatibility until we know for sure which angle configure for each device)
target_angle_3 = (target_angle + 90) % 360
target_angle_4 = (target_angle_3 + 180) % 360
# Create angular conditions in a vectorized way
angle_condition = np.zeros_like(angles_deg, dtype=bool)
# Process both angles simultaneously
for angle in [target_angle, target_angle_2, target_angle_3, target_angle_4]:
min_angle = (angle - angle_tolerance) % 360
max_angle = (angle + angle_tolerance) % 360
if min_angle > max_angle: # Interval crosses 0°
angle_condition |= (angles_deg >= min_angle) | (angles_deg <= max_angle)
else: # Normal interval
angle_condition |= (angles_deg >= min_angle) & (angles_deg <= max_angle)
# Combine conditions
combined_condition = freq_condition & angle_condition
# Apply attenuation directly (avoid creating a full mask)
if attenuation_factor == 0:
# Special case: complete suppression
if fft_spectrum.ndim == 2:
fft_spectrum[combined_condition] = 0
else: # 3D array
fft_spectrum[combined_condition, :] = 0
return fft_spectrum
elif attenuation_factor == 1:
# Special case: no attenuation
return fft_spectrum
else:
# General case: partial attenuation
if fft_spectrum.ndim == 2:
fft_spectrum[combined_condition] *= attenuation_factor
else: # 3D array
fft_spectrum[combined_condition, :] *= attenuation_factor
return fft_spectrum
def inverse_fourier_transform_image(fft_spectrum, is_color, original_shape=None):
"""
Performs an optimized inverse Fourier transform to reconstruct a PIL image.
Args:
fft_spectrum: Fourier transform result (complex array from rfft2)
is_color: Boolean indicating if the image is to be treated as color
Returns:
PIL.Image: Reconstructed image
"""
# Perform inverse Fourier transform with original shape if provided
if original_shape is not None:
img_reconstructed = np.fft.irfft2(fft_spectrum, s=original_shape)
else:
img_reconstructed = np.fft.irfft2(fft_spectrum)
# Normalize values between 0 and 255
img_reconstructed = np.clip(img_reconstructed, 0, 255)
img_reconstructed = img_reconstructed.astype(np.uint8)
# Convert to PIL image
if is_color and img_reconstructed.ndim == 3:
pil_image = Image.fromarray(img_reconstructed, mode='RGB')
else:
pil_image = Image.fromarray(img_reconstructed, mode='L')
return pil_image
def rgb_to_yuv(rgb_array):
"""
Convert RGB to YUV color space.
Y = luminance, U and V = chrominance
"""
# Coefficients for RGB to YUV conversion
rgb_to_yuv_matrix = np.array([
[0.299, 0.587, 0.114], # Y
[-0.14713, -0.28886, 0.436], # U
[0.615, -0.51499, -0.10001] # V
])
# Reshape for matrix multiplication
original_shape = rgb_array.shape
rgb_flat = rgb_array.reshape(-1, 3)
# Apply transformation
yuv_flat = rgb_flat @ rgb_to_yuv_matrix.T
# Reshape back
yuv_array = yuv_flat.reshape(original_shape)
return yuv_array
def yuv_to_rgb(yuv_array):
"""
Convert YUV to RGB color space.
"""
# Coefficients for YUV to RGB conversion
yuv_to_rgb_matrix = np.array([
[1.0, 0.0, 1.13983], # R
[1.0, -0.39465, -0.58060], # G
[1.0, 2.03211, 0.0] # B
])
# Reshape for matrix multiplication
original_shape = yuv_array.shape
yuv_flat = yuv_array.reshape(-1, 3)
# Apply transformation
rgb_flat = yuv_flat @ yuv_to_rgb_matrix.T
# Reshape back
rgb_array = rgb_flat.reshape(original_shape)
return rgb_array
def erase_rainbow_artifacts(img, is_color):
"""
Remove rainbow artifacts from grayscale or color images.
Args:
img: PIL Image (grayscale or RGB)
is_color: Boolean indicating if the image is to be treated as color
Returns:
PIL.Image: Cleaned image
"""
# Auto-detect color mode if not specified
if is_color is None:
color = img.mode in ('RGB', 'RGBA', 'L') and len(np.array(img).shape) == 3
if is_color and img.mode in ('RGB', 'RGBA'):
# Convert to RGB if needed
if img.mode == 'RGBA':
img = img.convert('RGB')
# Convert to numpy array
img_array = np.array(img, dtype=np.float32)
# Convert to YUV color space
yuv_array = rgb_to_yuv(img_array)
# Extract luminance channel (Y)
luminance = yuv_array[:, :, 0]
# Process only the luminance channel
fft_spectrum = fourier_transform_image(luminance)
clean_spectrum = attenuate_diagonal_frequencies(fft_spectrum)
clean_luminance = np.fft.irfft2(clean_spectrum, s=luminance.shape)
# Normalize and clip luminance
clean_luminance = np.clip(clean_luminance, 0, 255)
# Replace luminance in YUV array
yuv_array[:, :, 0] = clean_luminance
# Convert back to RGB
rgb_array = yuv_to_rgb(yuv_array)
rgb_array = np.clip(rgb_array, 0, 255).astype(np.uint8)
# Convert back to PIL image
clean_image = Image.fromarray(rgb_array, mode='RGB')
else:
# Grayscale processing (original behavior)
if img.mode != 'L':
img = img.convert('L')
# Get original image dimensions
original_shape = (img.height, img.width)
fft_spectrum = fourier_transform_image(img)
clean_spectrum = attenuate_diagonal_frequencies(fft_spectrum)
clean_image = inverse_fourier_transform_image(clean_spectrum, is_color, original_shape)
return clean_image

View File

@@ -18,7 +18,9 @@
# PERFORMANCE OF THIS SOFTWARE. # PERFORMANCE OF THIS SOFTWARE.
# #
from functools import lru_cache
import os import os
from hashlib import md5
from html.parser import HTMLParser from html.parser import HTMLParser
import subprocess import subprocess
from packaging.version import Version from packaging.version import Version
@@ -27,9 +29,6 @@ import sys
from traceback import format_tb from traceback import format_tb
IMAGE_TYPES = ('.png', '.jpg', '.jpeg', '.gif', '.webp', '.jp2', '.avif')
class HTMLStripper(HTMLParser): class HTMLStripper(HTMLParser):
def __init__(self): def __init__(self):
HTMLParser.__init__(self) HTMLParser.__init__(self)
@@ -48,17 +47,15 @@ class HTMLStripper(HTMLParser):
pass pass
def dot_clean(filetree):
for root, _, files in os.walk(filetree, topdown=False):
for name in files:
if name.startswith('._') or name == '.DS_Store':
if os.path.exists(os.path.join(root, name)):
os.remove(os.path.join(root, name))
def getImageFileName(imgfile): def getImageFileName(imgfile):
name, ext = os.path.splitext(imgfile) name, ext = os.path.splitext(imgfile)
ext = ext.lower() ext = ext.lower()
if (name.startswith('.') and len(name) == 1):
return None
if name.startswith('._'):
return None
if ext not in ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.jp2', '.j2k', '.jpx']:
return None
return [name, ext] return [name, ext]
@@ -101,10 +98,10 @@ def dependencyCheck(level):
if level > 2: if level > 2:
try: try:
from PySide6.QtCore import qVersion as qtVersion from PySide6.QtCore import qVersion as qtVersion
if Version('6.0.0') > Version(qtVersion()): if Version('6.5.1') > Version(qtVersion()):
missing.append('PySide 6.0.0') missing.append('PySide 6.5.1+')
except ImportError: except ImportError:
missing.append('PySide 6.0.0+') missing.append('PySide 6.5.1+')
try: try:
import raven import raven
except ImportError: except ImportError:
@@ -127,20 +124,27 @@ def dependencyCheck(level):
missing.append('python-slugify 1.2.1+') missing.append('python-slugify 1.2.1+')
try: try:
from PIL import __version__ as pillowVersion from PIL import __version__ as pillowVersion
if Version('8.3.0') > Version(pillowVersion): if Version('5.2.0') > Version(pillowVersion):
missing.append('Pillow 8.3.0+') missing.append('Pillow 5.2.0+')
except ImportError: except ImportError:
missing.append('Pillow 8.3.0+') missing.append('Pillow 5.2.0+')
try:
from pymupdf import __version__ as pymupdfVersion
if Version('1.16.1') > Version(pymupdfVersion):
missing.append('PyMuPDF 1.16.1+')
except ImportError:
missing.append('PyMuPDF 1.16.1+')
if len(missing) > 0: if len(missing) > 0:
print('ERROR: ' + ', '.join(missing) + ' is not installed!') print('ERROR: ' + ', '.join(missing) + ' is not installed!')
sys.exit(1) sys.exit(1)
@lru_cache
def available_archive_tools():
available = []
for tool in ['tar', '7z', 'unar', 'unrar']:
try:
subprocess_run([tool], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
available.append(tool)
except FileNotFoundError:
pass
return available
def subprocess_run(command, **kwargs): def subprocess_run(command, **kwargs):
if (os.name == 'nt'): if (os.name == 'nt'):
kwargs.setdefault('creationflags', subprocess.CREATE_NO_WINDOW) kwargs.setdefault('creationflags', subprocess.CREATE_NO_WINDOW)

View File

@@ -1,11 +0,0 @@
Pillow>=11.3.0
psutil>=5.9.5
requests>=2.31.0
python-slugify>=1.2.1
packaging>=23.2
mozjpeg-lossless-optimization>=1.2.0
natsort>=8.4.0
distro>=1.8.0
# Below requirements are compiled in Dockefile
# numpy==2.3.4
# PyMuPDF==1.26.6

View File

@@ -1,12 +0,0 @@
PySide6==6.4.3
Pillow>=11.3.0
psutil>=5.9.5
requests>=2.31.0
python-slugify>=1.2.1
raven>=6.0.0
packaging>=23.2
mozjpeg-lossless-optimization>=1.2.0
natsort>=8.4.0
distro>=1.8.0
numpy<2
PyMuPDF>=1.26.1

View File

@@ -1,12 +0,0 @@
PySide6==6.1.3
Pillow>=9
psutil>=5.9.5
requests>=2.31.0
python-slugify>=1.2.1
raven>=6.0.0
packaging>=23.2
mozjpeg-lossless-optimization>=1.2.0
natsort>=8.4.0
distro>=1.8.0
numpy==1.23.0
PyMuPDF>=1.16

View File

@@ -1,12 +1,11 @@
PySide6<6.10 PySide6>=6.5.1
Pillow>=11.3.0 Pillow>=5.2.0
psutil>=5.9.5 psutil>=5.9.5
requests>=2.31.0 requests>=2.31.0
python-slugify>=1.2.1,<9.0.0 python-slugify>=1.2.1
raven>=6.0.0 raven>=6.0.0
packaging>=23.2 packaging>=23.2
mozjpeg-lossless-optimization>=1.2.0 mozjpeg-lossless-optimization>=1.1.2
natsort>=8.4.0 natsort>=8.4.0
distro>=1.8.0 distro>=1.8.0
numpy>=1.22.4 numpy>=1.22.4,<2.0.0
PyMuPDF>=1.18.0

View File

@@ -8,8 +8,6 @@ Install as Python package:
Create EXE/APP: Create EXE/APP:
python3 setup.py build_binary python3 setup.py build_binary
python3 setup.py build_c2e
python3 setup.py build_c2p
""" """
import os import os
@@ -40,17 +38,10 @@ class BuildBinaryCommand(setuptools.Command):
if sys.platform == 'darwin': if sys.platform == 'darwin':
os.system('pyinstaller --hidden-import=_cffi_backend -y -D -i icons/comic2ebook.icns -n "Kindle Comic Converter" -w -s kcc.py') os.system('pyinstaller --hidden-import=_cffi_backend -y -D -i icons/comic2ebook.icns -n "Kindle Comic Converter" -w -s kcc.py')
# TODO /usr/bin/codesign --force -s "$MACOS_CERTIFICATE_NAME" --options runtime dist/Applications/Kindle\ Comic\ Converter.app -v # TODO /usr/bin/codesign --force -s "$MACOS_CERTIFICATE_NAME" --options runtime dist/Applications/Kindle\ Comic\ Converter.app -v
min_os = os.getenv('MACOSX_DEPLOYMENT_TARGET', '') os.system(f'appdmg kcc.json dist/kcc_macos_{platform.processor()}_{VERSION}.dmg')
if min_os.startswith('10.1'):
os.system(f'appdmg kcc.json dist/kcc_osx_{min_os.replace(".", "_")}_legacy_{VERSION}.dmg')
else:
os.system(f'appdmg kcc.json dist/kcc_macos_{platform.processor()}_{VERSION}.dmg')
sys.exit(0) sys.exit(0)
elif sys.platform == 'win32': elif sys.platform == 'win32':
if os.getenv('WINDOWS_7'): os.system('pyinstaller --hidden-import=_cffi_backend -y -F -i icons\\comic2ebook.ico -n KCC_' + VERSION + ' -w --noupx kcc.py')
os.system('pyinstaller --hidden-import=_cffi_backend -y -F -i icons\\comic2ebook.ico -n kcc_win7_legacy_' + VERSION + ' -w --noupx kcc.py')
else:
os.system('pyinstaller --hidden-import=_cffi_backend -y -F -i icons\\comic2ebook.ico -n KCC_' + VERSION + ' -w --noupx kcc.py')
sys.exit(0) sys.exit(0)
elif sys.platform == 'linux': elif sys.platform == 'linux':
os.system( os.system(
@@ -59,75 +50,10 @@ class BuildBinaryCommand(setuptools.Command):
else: else:
sys.exit(0) sys.exit(0)
# noinspection PyUnresolvedReferences
class BuildC2ECommand(setuptools.Command):
description = 'build binary c2e release'
user_options = []
def initialize_options(self):
pass
def finalize_options(self):
pass
# noinspection PyShadowingNames
def run(self):
VERSION = __version__
if sys.platform == 'darwin':
os.system('pyinstaller --hidden-import=_cffi_backend -y -D -i icons/comic2ebook.icns -n "KCC C2E" -c -s kcc-c2e.py')
# TODO /usr/bin/codesign --force -s "$MACOS_CERTIFICATE_NAME" --options runtime dist/Applications/Kindle\ Comic\ Converter.app -v
sys.exit(0)
elif sys.platform == 'win32':
if os.getenv('WINDOWS_7'):
os.system('pyinstaller --hidden-import=_cffi_backend -y -F -i icons\\comic2ebook.ico -n kcc_c2e_win7_legacy_' + VERSION + ' -c --noupx kcc-c2e.py')
else:
os.system('pyinstaller --hidden-import=_cffi_backend -y -F -i icons\\comic2ebook.ico -n kcc_c2e_' + VERSION + ' -c --noupx kcc-c2e.py')
sys.exit(0)
elif sys.platform == 'linux':
os.system(
'pyinstaller --hidden-import=_cffi_backend --hidden-import=queue -y -F -i icons/comic2ebook.ico -n kcc_c2e_linux_' + VERSION + ' kcc-c2e.py')
sys.exit(0)
else:
sys.exit(0)
# noinspection PyUnresolvedReferences
class BuildC2PCommand(setuptools.Command):
description = 'build binary c2p release'
user_options = []
def initialize_options(self):
pass
def finalize_options(self):
pass
# noinspection PyShadowingNames
def run(self):
VERSION = __version__
if sys.platform == 'darwin':
os.system('pyinstaller --hidden-import=_cffi_backend -y -n "KCC C2P" -c -s kcc-c2p.py')
# TODO /usr/bin/codesign --force -s "$MACOS_CERTIFICATE_NAME" --options runtime dist/Applications/Kindle\ Comic\ Converter.app -v
sys.exit(0)
elif sys.platform == 'win32':
if os.getenv('WINDOWS_7'):
os.system('pyinstaller --hidden-import=_cffi_backend -y -F -i icons\\comic2ebook.ico -n kcc_c2p_win7_legacy_' + VERSION + ' -c --noupx kcc-c2p.py')
else:
os.system('pyinstaller --hidden-import=_cffi_backend -y -F -i icons\\comic2ebook.ico -n kcc_c2p_' + VERSION + ' -c --noupx kcc-c2p.py')
sys.exit(0)
elif sys.platform == 'linux':
os.system(
'pyinstaller --hidden-import=_cffi_backend --hidden-import=queue -y -F -i icons/comic2ebook.ico -n kcc_c2p_linux_' + VERSION + ' kcc-c2p.py')
sys.exit(0)
else:
sys.exit(0)
setuptools.setup( setuptools.setup(
cmdclass={ cmdclass={
'build_binary': BuildBinaryCommand, 'build_binary': BuildBinaryCommand,
'build_c2e': BuildC2ECommand,
'build_c2p': BuildC2PCommand,
}, },
name=NAME, name=NAME,
version=VERSION, version=VERSION,
@@ -148,17 +74,16 @@ setuptools.setup(
}, },
packages=['kindlecomicconverter'], packages=['kindlecomicconverter'],
install_requires=[ install_requires=[
'PySide6>=6.0.0', 'pyside6>=6.5.1',
'Pillow>=9.3.0', 'Pillow>=5.2.0',
'psutil>=5.9.5', 'psutil>=5.9.5',
'requests>=2.31.0',
'python-slugify>=1.2.1,<9.0.0', 'python-slugify>=1.2.1,<9.0.0',
'raven>=6.0.0', 'raven>=6.0.0',
'mozjpeg-lossless-optimization>=1.2.0', 'requests>=2.31.0',
'mozjpeg-lossless-optimization>=1.1.2',
'natsort>=8.4.0', 'natsort>=8.4.0',
'distro>=1.8.0', 'distro',
'numpy>=1.22.4', 'numpy>=1.22.4,<2.0.0'
'PyMuPDF>=1.16.1',
], ],
classifiers=[], classifiers=[],
zip_safe=False, zip_safe=False,