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

Compare commits

..

2 Commits

Author SHA1 Message Date
Alex Xu
d2981c0ceb Bump version to 9.3.0 2025-11-09 18:07:50 -08:00
José Cerezo
8268552ac7 Optimized Docker image (#1155)
* Optimized Docker image

* Divided Dockerfile into two images

* Fixed dockerfile path

* Updated workflows

* Added remaining packages in Dockerfile-base

* Updated workflows
2025-11-09 15:58:55 -08:00
29 changed files with 1338 additions and 2189 deletions

View File

@@ -38,7 +38,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v5
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL

View File

@@ -0,0 +1,45 @@
name: Build and Publish Base Docker Image
on:
workflow_dispatch:
push:
branches:
- master
paths:
- 'requirements-docker.txt'
- 'Dockerfile-base'
jobs:
build_and_publish_base_image:
runs-on: ubuntu-latest
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ 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: Build and push
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64,linux/arm/v7
file: Dockerfile-base
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/kcc:base-latest
ghcr.io/${{ github.repository_owner }}/kcc:base-${{ steps.release_date.outputs.release_date }}
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/kcc:base-cache
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/kcc:base-cache,mode=max

View File

@@ -1,4 +1,4 @@
name: Build and Publish Docker Image name: Build and publish final image
on: on:
workflow_dispatch: workflow_dispatch:
@@ -6,7 +6,7 @@ on:
tags: tags:
- 'v*.*.*' - 'v*.*.*'
# Don't trigger if it's just a documentation update # Don't trigger if it's just a documentation update or a base image update
paths-ignore: paths-ignore:
- '**.md' - '**.md'
- '**.MD' - '**.MD'
@@ -15,14 +15,13 @@ on:
- 'LICENSE' - 'LICENSE'
- '.gitattributes' - '.gitattributes'
- '.gitignore' - '.gitignore'
- 'requirements-docker.txt'
- 'Dockerfile-base'
jobs: jobs:
build_and_publish_base_image: build_and_publish_image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout
uses: actions/checkout@v6
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
@@ -36,32 +35,17 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 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 - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ghcr.io/${{ github.repository_owner }}/kcc 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 - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64,linux/arm/v7
context: .
push: true push: true
tags: | tags: ${{ steps.meta.outputs.tags }}
${{ steps.meta.outputs.tags }} cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/kcc:final-cache
cache-from: | cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/kcc:final-cache,mode=max
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,7 +25,7 @@ jobs:
build: build:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v5
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@@ -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,12 +25,10 @@ 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@v5
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@@ -38,7 +36,7 @@ jobs:
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
@@ -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,7 +87,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: |
dist/*.dmg dist/*.dmg
- name: Clean up keychain and provisioning profile - name: Clean up keychain and provisioning profile

View File

@@ -23,7 +23,7 @@ jobs:
build: build:
strategy: strategy:
matrix: matrix:
os: [ macos-15-intel ] os: [ macos-13 ]
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
env: env:
# We need the official Python, because the GA ones only support newer macOS versions # We need the official Python, because the GA ones only support newer macOS versions
@@ -31,7 +31,7 @@ jobs:
PYTHON_VERSION: 3.11.9 PYTHON_VERSION: 3.11.9
MACOSX_DEPLOYMENT_TARGET: '10.14' MACOSX_DEPLOYMENT_TARGET: '10.14'
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v5
- name: Get Python - name: Get Python
run: curl https://www.python.org/ftp/python/3.11.9/python-3.11.9-macos11.pkg -o "python.pkg" run: curl https://www.python.org/ftp/python/3.11.9/python-3.11.9-macos11.pkg -o "python.pkg"
- name: Install Python - name: Install Python
@@ -40,7 +40,7 @@ jobs:
- name: Install Python dependencies - name: Install Python dependencies
run: | run: |
python3 --version python3 --version
pip3 install --upgrade pip pyinstaller certifi pip3 install --upgrade pip setuptools wheel pyinstaller certifi
pip3 install --upgrade -r requirements-osx-legacy.txt pip3 install --upgrade -r requirements-osx-legacy.txt
./gen_ui_files.sh ./gen_ui_files.sh
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
@@ -51,7 +51,7 @@ jobs:
run: | run: |
python3 setup.py build_binary python3 setup.py build_binary
- name: upload build - name: upload build
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v4
with: with:
name: osx-build-${{ runner.arch }} name: osx-build-${{ runner.arch }}
path: dist/*.dmg path: dist/*.dmg
@@ -60,7 +60,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
dist/*.dmg dist/*.dmg

View File

@@ -35,7 +35,7 @@ jobs:
command: build_c2p command: build_c2p
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v5
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@@ -45,7 +45,7 @@ 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
@@ -53,12 +53,12 @@ jobs:
python setup.py ${{ matrix.command }} python setup.py ${{ matrix.command }}
- 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-${{ matrix.entry }}
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.3
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 +73,6 @@ 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: |
dist/*.exe dist/*.exe

View File

@@ -27,7 +27,7 @@ jobs:
env: env:
WINDOWS_7: 1 WINDOWS_7: 1
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v5
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@@ -37,7 +37,7 @@ 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-win7.txt pip install -r requirements-win7.txt
pip install certifi pyinstaller --no-binary pyinstaller pip install certifi pyinstaller --no-binary pyinstaller
.\gen_ui_files.bat .\gen_ui_files.bat
@@ -46,26 +46,15 @@ jobs:
python setup.py build_binary 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: windows7-build name: windows7-build
path: dist/*.exe 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 - name: Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
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: |
dist/*.exe dist/*.exe

View File

@@ -1,77 +1,20 @@
# STAGE 1: BUILDER FROM ghcr.io/ciromattia/kcc:base-latest
# Contains all build tools and dev dependencies, will be discarded
FROM python:3.13-slim-bullseye AS builder
# Install system dependencies
RUN set -x && \
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 \
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/ COPY . /opt/kcc/
WORKDIR /opt/kcc
ENV PATH="/opt/venv/bin:$PATH"
# Setup executable and version file # Setup executable and version file
RUN \ RUN \
chmod +x /opt/kcc/entrypoint.sh && \ chmod +x /opt/kcc/entrypoint.sh && \
ln -s /opt/kcc/kcc-c2e.py /usr/local/bin/c2e && \ 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/kcc-c2p.py /usr/local/bin/c2p && \
ln -s /opt/kcc/entrypoint.sh /usr/local/bin/entrypoint && \ 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 cat /opt/kcc/kindlecomicconverter/__init__.py | grep version | awk '{print $3}' | sed "s/'//g" > /IMAGE_VERSION
LABEL com.kcc.name="Kindle Comic Converter" \ LABEL com.kcc.name="Kindle Comic Converter" \
com.kcc.author="Ciro Mattia Gonano, Paweł Jastrzębski and Darodi" \ 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.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.source='https://github.com/ciromattia/kcc' \
org.opencontainers.image.authors='Darodi and José Cerezo' \ org.opencontainers.image.title="Kindle Comic Converter"
org.opencontainers.image.url='https://github.com/ciromattia/kcc' \
org.opencontainers.image.vendor='ciromattia' \
org.opencontainers.image.licenses='ISC'
ENTRYPOINT ["entrypoint"] ENTRYPOINT ["entrypoint"]
CMD ["-h"] CMD ["-h"]

44
Dockerfile-base Normal file
View File

@@ -0,0 +1,44 @@
# STAGE 1: BUILDER
# Contains all build tools and dev dependencies, will be discarded
FROM python:3.13-slim-bullseye AS builder
ARG TARGETARCH
# Install system dependencies
RUN set -x && \
BUILD_DEPS="build-essential cmake libffi-dev libfreetype6-dev libfontconfig1-dev libpng-dev libjpeg-dev libssl-dev libxft-dev make python3-dev python3-setuptools python3-wheel" && \
RUNTIME_DEPS="bash ca-certificates chrpath locales locales-all libfreetype6 libfontconfig1 p7zip-full python3 python3-pip unrar-free libgl1" && \
DEBIAN_FRONTEND=noninteractive apt-get update -y && \
apt-get install -y --no-install-recommends ${BUILD_DEPS} ${RUNTIME_DEPS}
# Install Python dependencies using virtual environment
COPY requirements-docker.txt .
RUN \
set -x && \
python -m venv /opt/venv && \
. /opt/venv/bin/activate && \
pip install --upgrade pip && \
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 unrar-free && \
rm -rf /var/lib/apt/lists/*
# Copy artifacts from builder
COPY --from=builder /opt/venv /opt/venv
WORKDIR /opt/kcc
ENV PATH="/opt/venv/bin:$PATH"
LABEL com.kcc.name="Kindle Comic Converter" \
com.kcc.author="Ciro Mattia Gonano, Paweł Jastrzębski and Darodi" \
org.opencontainers.image.description='Kindle Comic Converter Base Image' \
org.opencontainers.image.source='https://github.com/ciromattia/kcc' \
org.opencontainers.image.title="Kindle Comic Converter Base Image"

106
README.md
View File

@@ -7,19 +7,12 @@
[![Github All Releases](https://img.shields.io/github/downloads/ciromattia/kcc/total.svg)](https://github.com/ciromattia/kcc/releases) [![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 **Kindle Comic Converter** optimizes black & white comics and manga for E-ink ereaders
like Kindle, Kobo, ReMarkable, and more. like Kindle, Kobo, ReMarkable, and more.
Pages display in fullscreen without margins, Pages display in fullscreen without margins,
with proper fixed layout support. with proper fixed layout support.
Supported input formats include JPG/PNG image files in folders, archives, or PDFs. Supported input formats include JPG/PNG image files in folders, archives, or PDFs.
Supported output formats include MOBI/AZW3, EPUB, KEPUB, CBZ, and PDF. 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! **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 When using a reMarkable profile (Rmk1, Rmk2, RmkPP), the format automatically defaults to PDF
@@ -33,7 +26,7 @@ which have different requirements than normal LCD screens.
Combining that with downscaling to your specific device's screen resolution Combining that with 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. can result in filesize reductions of hundreds of MB per volume with no visible quality loss on eink.
This can also improve battery life, page turn speed, and general performance This can also improve battery life, page turn speed, and general performance
on underpowered ereaders with small memory and storage capacities. on underpowered ereaders with small storage capacities.
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: 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. 1) faded black levels causing unneccessarily low contrast, which is hard to see and can cause eyestrain.
@@ -41,7 +34,6 @@ KCC avoids many common formatting issues (some of which occur [even on the Kindl
3) Not utilizing the full 1860x2480 resolution of the 10" Kindle Scribe 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 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 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: The GUI looks like this, built in Qt6, with my most commonly used settings:
@@ -54,9 +46,7 @@ You can change the default output directory by holding `Shift` while clicking th
Then just drag and drop the generated output files onto your device's documents folder via USB. 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. 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 YouTube tutorial (please subscribe): https://www.youtube.com/watch?v=IR2Fhcm9658
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.
@@ -102,19 +92,19 @@ 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 macOS 12+)
There are also legacy macOS 10.14+ and Windows 7 experimental versions available. 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 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? - Should I use Calibre?
- No. Calibre doesn't properly support fixed layout EPUB/MOBI, so modifying KCC output (even just metadata!) in Calibre can break the formatting. - No. Calibre doesn't properly support fixed layout EPUB/MOBI, so modifying KCC output (even just metadata!) in Calibre will break the formatting.
Viewing KCC output in Calibre will also not work properly. Viewing KCC output in Calibre will also not work properly.
On 7th gen and later Kindles running firmware 5.15.1+, you can get cover thumbnails simply by USB dropping into documents folder. On 7th gen and later Kindles running firmware 5.15.1+, you can get cover thumbnails simply by USB dropping into documents folder.
On 6th gen and older, you can get cover thumbnails by keeping Kindle plugged in during conversion. On 6th gen and older, you can get cover thumbnails by keeping Kindle plugged in during conversion.
@@ -187,44 +177,38 @@ 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), 'K57': ("Kindle 5/7", (600, 800), Palette16, 1.8),
'K810': ("Kindle 8/10", (600, 800), Palette16, 1.0), 'K810': ("Kindle 8/10", (600, 800), Palette16, 1.8),
'KDX': ("Kindle DX/DXG", (824, 1000), Palette16, 1.0), 'KDX': ("Kindle DX/DXG", (824, 1000), Palette16, 1.8),
'KPW': ("Kindle Paperwhite 1/2", (758, 1024), Palette16, 1.0), 'KPW': ("Kindle Paperwhite 1/2", (758, 1024), Palette16, 1.8),
'KV': ("Kindle Voyage", (1072, 1448), Palette16, 1.0), 'KV': ("Kindle Voyage, (1072, 1448), Palette16, 1.8),
'KPW34': ("Kindle Paperwhite 3/4", (1072, 1448), Palette16, 1.0), 'KPW34': ("Kindle Paperwhite 3/4/Oasis", (1072, 1448), Palette16, 1.8),
'KPW5': ("Kindle Paperwhite 5/Signature Edition", (1236, 1648), Palette16, 1.0), 'KPW5': ("Kindle Paperwhite 5/Signature Edition", (1236, 1648), Palette16, 1.8),
'KO': ("Kindle Oasis 2/3/Paperwhite 12", (1264, 1680), Palette16, 1.0), 'KO': ("Kindle Oasis 2/3/Paperwhite 12/Colorsoft 12", (1264, 1680), Palette16, 1.8),
'KCS': ("Kindle Colorsoft", (1264, 1680), Palette16, 1.0), 'KS': ("Kindle Scribe", (1860, 2480), Palette16, 1.8),
'KS1860': ("Kindle 1860", (1860, 1920), Palette16, 1.0), 'KoMT': ("Kobo Mini/Touch", (600, 800), Palette16, 1.8),
'KS1920': ("Kindle 1920", (1920, 1920), Palette16, 1.0), 'KoG': ("Kobo Glo", (768, 1024), Palette16, 1.8),
'KS': ("Kindle Scribe 1/2", (1860, 2480), Palette16, 1.0), 'KoGHD': ("Kobo Glo HD", (1072, 1448), Palette16, 1.8),
'KS3': ("Kindle Scribe 3", (1986, 2648), Palette16, 1.0), 'KoA': ("Kobo Aura", (758, 1024), Palette16, 1.8),
'KSCS': ("Kindle Scribe Colorsoft", (1986, 2648), Palette16, 1.0), 'KoAHD': ("Kobo Aura HD", (1080, 1440), Palette16, 1.8),
'KoMT': ("Kobo Mini/Touch", (600, 800), Palette16, 1.0), 'KoAH2O': ("Kobo Aura H2O", (1080, 1430), Palette16, 1.8),
'KoG': ("Kobo Glo", (768, 1024), Palette16, 1.0), 'KoAO': ("Kobo Aura ONE", (1404, 1872), Palette16, 1.8),
'KoGHD': ("Kobo Glo HD", (1072, 1448), Palette16, 1.0), 'KoN': ("Kobo Nia", (758, 1024), Palette16, 1.8),
'KoA': ("Kobo Aura", (758, 1024), Palette16, 1.0), 'KoC': ("Kobo Clara HD/Kobo Clara 2E", (1072, 1448), Palette16, 1.8),
'KoAHD': ("Kobo Aura HD", (1080, 1440), Palette16, 1.0), 'KoCC': ("Kobo Clara Colour", (1072, 1448), Palette16, 1.8),
'KoAH2O': ("Kobo Aura H2O", (1080, 1430), Palette16, 1.0), 'KoL': ("Kobo Libra H2O/Kobo Libra 2", (1264, 1680), Palette16, 1.8),
'KoAO': ("Kobo Aura ONE", (1404, 1872), Palette16, 1.0), 'KoLC': ("Kobo Libra Colour", (1264, 1680), Palette16, 1.8),
'KoN': ("Kobo Nia", (758, 1024), Palette16, 1.0), 'KoF': ("Kobo Forma", (1440, 1920), Palette16, 1.8),
'KoC': ("Kobo Clara HD/Kobo Clara 2E", (1072, 1448), Palette16, 1.0), 'KoS': ("Kobo Sage", (1440, 1920), Palette16, 1.8),
'KoCC': ("Kobo Clara Colour", (1072, 1448), Palette16, 1.0), 'KoE': ("Kobo Elipsa", (1404, 1872), Palette16, 1.8),
'KoL': ("Kobo Libra H2O/Kobo Libra 2", (1264, 1680), Palette16, 1.0), 'Rmk1': ("reMarkable 1", (1404, 1872), Palette16, 1.8),
'KoLC': ("Kobo Libra Colour", (1264, 1680), Palette16, 1.0), 'Rmk2': ("reMarkable 2", (1404, 1872), Palette16, 1.8),
'KoF': ("Kobo Forma", (1440, 1920), Palette16, 1.0), 'RmkPP': ("reMarkable Paper Pro", (1620, 2160), Palette16, 1.8),
'KoS': ("Kobo Sage", (1440, 1920), Palette16, 1.0), 'OTHER': ("Other", (0, 0), Palette16, 1.8),
'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:
@@ -248,8 +232,6 @@ MAIN:
PROCESSING: PROCESSING:
-n, --noprocessing Do not modify image and ignore any profile or processing option -n, --noprocessing Do not modify image and ignore any profile 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
@@ -270,19 +252,11 @@ 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
--nosmartcovercrop Disable 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.
--tempdir Create temporary files directory on source file drive.
OUTPUT SETTINGS: OUTPUT SETTINGS:
-o OUTPUT, --output OUTPUT -o OUTPUT, --output OUTPUT
@@ -299,7 +273,6 @@ OUTPUT SETTINGS:
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.
--rotatefirst Put rotated spread first in spread splitter option. --rotatefirst Put rotated spread first in spread splitter option.
--filefusion Combines all input files into a single file. --filefusion Combines all input files into a single file.
--eraserainbow Erase rainbow effect on color eink screen by attenuating interfering frequencies --eraserainbow Erase rainbow effect on color eink screen by attenuating interfering frequencies
@@ -345,7 +318,6 @@ 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

16
environment.yml Normal file
View File

@@ -0,0 +1,16 @@
name: kcc
channels:
- conda-forge
- defaults
dependencies:
- python=3.11
- Pillow>=11.3.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

1304
gui/KCC.ui

File diff suppressed because it is too large Load Diff

View File

@@ -192,18 +192,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>

View File

@@ -22,7 +22,7 @@ import itertools
from pathlib import Path from pathlib import Path
from PySide6.QtCore import (QSize, QUrl, Qt, Signal, QIODeviceBase, QEvent, QThread, QSettings) from PySide6.QtCore import (QSize, QUrl, Qt, Signal, QIODeviceBase, QEvent, QThread, QSettings)
from PySide6.QtGui import (QColor, QIcon, QPixmap, QDesktopServices) from PySide6.QtGui import (QColor, QIcon, QPixmap, QDesktopServices)
from PySide6.QtWidgets import (QApplication, QLabel, QListWidgetItem, QMainWindow, QSystemTrayIcon, QFileDialog, QMessageBox, QDialog, QTreeView, QAbstractItemView) from PySide6.QtWidgets import (QApplication, QLabel, QListWidgetItem, QMainWindow, QSystemTrayIcon, QFileDialog, QMessageBox, QDialog)
from PySide6.QtNetwork import (QLocalSocket, QLocalServer) from PySide6.QtNetwork import (QLocalSocket, QLocalServer)
import os import os
@@ -42,7 +42,7 @@ from raven import Client
from tempfile import gettempdir from tempfile import gettempdir
from .shared import HTMLStripper, sanitizeTrace, walkLevel, subprocess_run from .shared import HTMLStripper, sanitizeTrace, walkLevel, subprocess_run
from .comicarchive import SEVENZIP, TAR, available_archive_tools from .comicarchive import SEVENZIP, available_archive_tools
from . import __version__ from . import __version__
from . import comic2ebook from . import comic2ebook
from . import metadata from . import metadata
@@ -195,7 +195,7 @@ class VersionThread(QThread):
icon = 'bindle' icon = 'bindle'
if category == 'kofi': if category == 'kofi':
icon = 'kofi' icon = 'kofi'
message = f"{payload.get('name')}" message = f"<b>{payload.get('name')}</b>"
if payload.get('link'): if payload.get('link'):
message = '<a href="{}"><b>{}</b></a>'.format(payload.get('link'), payload.get('name')) message = '<a href="{}"><b>{}</b></a>'.format(payload.get('link'), payload.get('name'))
if payload.get('showDeadline'): if payload.get('showDeadline'):
@@ -327,22 +327,12 @@ class WorkerThread(QThread):
options.maximizestrips = True options.maximizestrips = True
if GUI.disableProcessingBox.isChecked(): if GUI.disableProcessingBox.isChecked():
options.noprocessing = True options.noprocessing = True
if GUI.pdfExtractBox.isChecked():
options.pdfextract = True
if GUI.pdfWidthBox.isChecked():
options.pdfwidth = True
if GUI.noSmartCoverCropBox.isChecked():
options.nosmartcovercrop = True
if GUI.coverFillBox.isChecked():
options.coverfill = True
if GUI.metadataTitleBox.checkState() == Qt.CheckState.PartiallyChecked: if GUI.metadataTitleBox.checkState() == Qt.CheckState.PartiallyChecked:
options.metadatatitle = 1 options.metadatatitle = 1
elif GUI.metadataTitleBox.checkState() == Qt.CheckState.Checked: elif GUI.metadataTitleBox.checkState() == Qt.CheckState.Checked:
options.metadatatitle = 2 options.metadatatitle = 2
if GUI.deleteBox.isChecked(): if GUI.deleteBox.isChecked():
options.delete = True options.delete = True
if GUI.tempDirBox.isChecked():
options.tempdir = True
if GUI.spreadShiftBox.isChecked(): if GUI.spreadShiftBox.isChecked():
options.spreadshift = True options.spreadshift = True
if GUI.fileFusionBox.isChecked(): if GUI.fileFusionBox.isChecked():
@@ -351,24 +341,12 @@ class WorkerThread(QThread):
options.filefusion = False options.filefusion = False
if GUI.noRotateBox.isChecked(): if GUI.noRotateBox.isChecked():
options.norotate = True options.norotate = True
if GUI.rotateRightBox.isChecked():
options.rotateright = True
if GUI.rotateFirstBox.isChecked(): if GUI.rotateFirstBox.isChecked():
options.rotatefirst = True options.rotatefirst = True
if GUI.forcePngRgbBox.isChecked():
options.force_png_rgb = True
if GUI.mozJpegBox.checkState() == Qt.CheckState.PartiallyChecked: if GUI.mozJpegBox.checkState() == Qt.CheckState.PartiallyChecked:
options.forcepng = True options.forcepng = True
elif GUI.mozJpegBox.checkState() == Qt.CheckState.Checked: elif GUI.mozJpegBox.checkState() == Qt.CheckState.Checked:
options.mozjpeg = True options.mozjpeg = True
if GUI.webpBox.isChecked():
options.webp = True
if GUI.pngLegacyBox.isChecked():
options.pnglegacy = True
if GUI.noQuantizeBox.isChecked():
options.noquantize = True
if GUI.jpegQualityBox.isChecked():
options.jpegquality = GUI.jpegQualitySpinBox.value()
if GUI.currentMode > 2: if GUI.currentMode > 2:
options.customwidth = str(GUI.widthBox.value()) options.customwidth = str(GUI.widthBox.value())
options.customheight = str(GUI.heightBox.value()) options.customheight = str(GUI.heightBox.value())
@@ -404,14 +382,13 @@ class WorkerThread(QThread):
error_message = 'Process Failed. Custom title can\'t be set when processing more than 1 source.\nDid you forget to check fusion?' error_message = 'Process Failed. Custom title can\'t be set when processing more than 1 source.\nDid you forget to check fusion?'
print(error_message) print(error_message)
MW.addMessage.emit(error_message, 'error', True) MW.addMessage.emit(error_message, 'error', True)
for i, job in enumerate(currentJobs, start=1): for job in currentJobs:
job_progress_number = f'[{i}/{len(currentJobs)}] '
sleep(0.5) sleep(0.5)
if not self.conversionAlive: if not self.conversionAlive:
self.clean() self.clean()
return return
self.errors = False self.errors = False
MW.addMessage.emit(f'<b>{job_progress_number}Source:</b> ' + job, 'info', False) MW.addMessage.emit('<b>Source:</b> ' + job, 'info', False)
if gui_current_format == 'CBZ': if gui_current_format == 'CBZ':
MW.addMessage.emit('Creating CBZ files', 'info', False) MW.addMessage.emit('Creating CBZ files', 'info', False)
GUI.progress.content = 'Creating CBZ files' GUI.progress.content = 'Creating CBZ files'
@@ -425,7 +402,7 @@ class WorkerThread(QThread):
jobargv.append(job) jobargv.append(job)
try: try:
comic2ebook.options = comic2ebook.checkOptions(copy(options)) comic2ebook.options = comic2ebook.checkOptions(copy(options))
outputPath = comic2ebook.makeBook(job, self, job_progress_number) outputPath = comic2ebook.makeBook(job, self)
MW.hideProgressBar.emit() MW.hideProgressBar.emit()
except UserWarning as warn: except UserWarning as warn:
if not self.conversionAlive: if not self.conversionAlive:
@@ -467,7 +444,7 @@ class WorkerThread(QThread):
else: else:
MW.addMessage.emit('Creating EPUB files... <b>Done!</b>', 'info', True) MW.addMessage.emit('Creating EPUB files... <b>Done!</b>', 'info', True)
if 'MOBI' in gui_current_format: if 'MOBI' in gui_current_format:
MW.progressBarTick.emit(f'{job_progress_number}Creating MOBI files') MW.progressBarTick.emit('Creating MOBI files')
MW.progressBarTick.emit(str(len(outputPath) * 2 + 1)) MW.progressBarTick.emit(str(len(outputPath) * 2 + 1))
MW.progressBarTick.emit('tick') MW.progressBarTick.emit('tick')
MW.addMessage.emit('Creating MOBI files', 'info', False) MW.addMessage.emit('Creating MOBI files', 'info', False)
@@ -541,7 +518,6 @@ class WorkerThread(QThread):
if os.path.exists(item.replace('.epub', '.mobi')): if os.path.exists(item.replace('.epub', '.mobi')):
os.remove(item.replace('.epub', '.mobi')) os.remove(item.replace('.epub', '.mobi'))
MW.addMessage.emit('KindleGen failed to create MOBI!', 'error', False) MW.addMessage.emit('KindleGen failed to create MOBI!', 'error', False)
MW.addMessage.emit(self.kindlegenErrorCode[1], 'error', False)
MW.addTrayMessage.emit('KindleGen failed to create MOBI!', 'Critical') MW.addTrayMessage.emit('KindleGen failed to create MOBI!', 'Critical')
if self.kindlegenErrorCode[0] == 1 and self.kindlegenErrorCode[1] != '': if self.kindlegenErrorCode[0] == 1 and self.kindlegenErrorCode[1] != '':
MW.showDialog.emit("KindleGen error:\n\n" + self.kindlegenErrorCode[1], 'error') MW.showDialog.emit("KindleGen error:\n\n" + self.kindlegenErrorCode[1], 'error')
@@ -633,30 +609,12 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
'Comic (*.pdf);;All (*.*)') 'Comic (*.pdf);;All (*.*)')
for fname in fnames[0]: for fname in fnames[0]:
if fname != '': if fname != '':
if sys.platform.startswith('win'):
fname = fname.replace('/', '\\')
self.lastPath = os.path.abspath(os.path.join(fname, os.pardir)) self.lastPath = os.path.abspath(os.path.join(fname, os.pardir))
GUI.jobList.addItem(fname) GUI.jobList.addItem(fname)
GUI.jobList.scrollToBottom() GUI.jobList.scrollToBottom()
def selectDir(self):
if self.needClean:
self.needClean = False
GUI.jobList.clear()
dialog = QFileDialog(MW, 'Select input folder(s)', self.lastPath)
dialog.setFileMode(QFileDialog.FileMode.Directory)
dialog.setOption(QFileDialog.Option.ShowDirsOnly, True)
dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
dialog.findChild(QTreeView).setSelectionMode(QAbstractItemView.ExtendedSelection)
if dialog.exec():
dnames = dialog.selectedFiles()
for dname in dnames:
if dname != '':
self.lastPath = os.path.abspath(os.path.join(dname, os.pardir))
GUI.jobList.addItem(dname)
GUI.jobList.scrollToBottom()
def selectFileMetaEditor(self, sname): def selectFileMetaEditor(self, sname):
if not sname: if not sname:
if QApplication.keyboardModifiers() == Qt.ShiftModifier: if QApplication.keyboardModifiers() == Qt.ShiftModifier:
@@ -698,18 +656,6 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
# noinspection PyCallByClass # noinspection PyCallByClass
QDesktopServices.openUrl(QUrl('https://ko-fi.com/eink_dude')) QDesktopServices.openUrl(QUrl('https://ko-fi.com/eink_dude'))
def openHumble(self):
# noinspection PyCallByClass
QDesktopServices.openUrl(QUrl('https://humblebundleinc.sjv.io/3JaR3A'))
def openYouTube(self):
# noinspection PyCallByClass
QDesktopServices.openUrl(QUrl('https://www.youtube.com/@eink-dude'))
def openDiscord(self):
# noinspection PyCallByClass
QDesktopServices.openUrl(QUrl('https://discord.gg/um5JRKwmGT'))
def modeChange(self, mode): def modeChange(self, mode):
if mode == 1: if mode == 1:
self.currentMode = 1 self.currentMode = 1
@@ -777,12 +723,6 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
GUI.croppingWidget.setVisible(False) GUI.croppingWidget.setVisible(False)
self.changeCroppingPower(100) # 1.0 self.changeCroppingPower(100) # 1.0
def togglejpegqualityBox(self, value):
if value:
GUI.jpegQualityWidget.setVisible(True)
else:
GUI.jpegQualityWidget.setVisible(False)
def togglewebtoonBox(self, value): def togglewebtoonBox(self, value):
if value: if value:
self.addMessage('You can choose a taller device profile to get taller cuts in webtoon mode.', 'info') self.addMessage('You can choose a taller device profile to get taller cuts in webtoon mode.', 'info')
@@ -813,7 +753,7 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
GUI.rotateBox.setEnabled(True) GUI.rotateBox.setEnabled(True)
GUI.borderBox.setEnabled(True) GUI.borderBox.setEnabled(True)
profile = GUI.profiles[str(GUI.deviceBox.currentText())] profile = GUI.profiles[str(GUI.deviceBox.currentText())]
if not profile['Label'].startswith('KS'): if profile['Label'] != 'KS':
GUI.upscaleBox.setEnabled(True) GUI.upscaleBox.setEnabled(True)
GUI.croppingBox.setEnabled(True) GUI.croppingBox.setEnabled(True)
GUI.interPanelCropBox.setEnabled(True) GUI.interPanelCropBox.setEnabled(True)
@@ -838,20 +778,12 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
def toggleImageFormatBox(self, value): def toggleImageFormatBox(self, value):
profile = GUI.profiles[str(GUI.deviceBox.currentText())] profile = GUI.profiles[str(GUI.deviceBox.currentText())]
if value == 1: if value == 1:
if profile['Label'].startswith('KS'): if profile['Label'] == 'KS':
current_format = GUI.formats[str(GUI.formatBox.currentText())]['format'] current_format = GUI.formats[str(GUI.formatBox.currentText())]['format']
for bad_format in ('MOBI', 'EPUB'): for bad_format in ('MOBI', 'EPUB'):
if bad_format in current_format: if bad_format in current_format:
self.addMessage('Scribe PNG MOBI/EPUB has a lot of problems like blank pages/sections. Use JPG instead.', 'warning') self.addMessage('Scribe PNG MOBI/EPUB has a lot of problems like blank pages/sections. Use JPG instead.', 'warning')
break break
GUI.pngLegacyBox.setEnabled(True)
GUI.noQuantizeBox.setEnabled(True)
GUI.forcePngRgbBox.setEnabled(True)
else:
GUI.pngLegacyBox.setEnabled(False)
GUI.noQuantizeBox.setEnabled(False)
GUI.forcePngRgbBox.setEnabled(False)
def togglechunkSizeCheckBox(self, value): def togglechunkSizeCheckBox(self, value):
GUI.chunkSizeWidget.setVisible(value) GUI.chunkSizeWidget.setVisible(value)
@@ -908,7 +840,7 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
if not GUI.webtoonBox.isChecked(): if not GUI.webtoonBox.isChecked():
GUI.qualityBox.setEnabled(profile['PVOptions']) GUI.qualityBox.setEnabled(profile['PVOptions'])
GUI.upscaleBox.setChecked(profile['DefaultUpscale']) GUI.upscaleBox.setChecked(profile['DefaultUpscale'])
if profile['Label'].startswith('KS'): if profile['Label'] == 'KS':
GUI.upscaleBox.setDisabled(True) GUI.upscaleBox.setDisabled(True)
else: else:
if not GUI.webtoonBox.isChecked(): if not GUI.webtoonBox.isChecked():
@@ -919,10 +851,6 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
if bad_format in current_format: if bad_format in current_format:
self.addMessage('Colorsoft MOBI/EPUB can have blank pages. Just go back a few pages, exit, and reenter book.', 'info') self.addMessage('Colorsoft MOBI/EPUB can have blank pages. Just go back a few pages, exit, and reenter book.', 'info')
break break
elif profile['Label'] == 'KDX':
GUI.mozJpegBox.setCheckState(Qt.CheckState.PartiallyChecked)
GUI.borderBox.setCheckState(Qt.CheckState.PartiallyChecked)
GUI.pngLegacyBox.setChecked(True)
if not profile['PVOptions']: if not profile['PVOptions']:
GUI.qualityBox.setChecked(False) GUI.qualityBox.setChecked(False)
if str(GUI.deviceBox.currentText()) == 'Other': if str(GUI.deviceBox.currentText()) == 'Other':
@@ -949,7 +877,7 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
elif not GUI.webtoonBox.isChecked(): elif not GUI.webtoonBox.isChecked():
GUI.chunkSizeCheckBox.setEnabled(True) GUI.chunkSizeCheckBox.setEnabled(True)
if GUI.formats[str(GUI.formatBox.currentText())]['format'] in ('CBZ', 'PDF') and not GUI.webtoonBox.isChecked(): if GUI.formats[str(GUI.formatBox.currentText())]['format'] in ('CBZ', 'PDF') and not GUI.webtoonBox.isChecked():
self.addMessage("Partially check W/B Margins if you don't want KCC to extend the image margins.", 'info') self.addMessage("Partially check W/B Margins if you don't want KCC to extend the image margins.", 'info')
def stripTags(self, html): def stripTags(self, html):
s = HTMLStripper() s = HTMLStripper()
@@ -1077,27 +1005,15 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
'colorBox': GUI.colorBox.checkState(), 'colorBox': GUI.colorBox.checkState(),
'eraseRainbowBox': GUI.eraseRainbowBox.checkState(), 'eraseRainbowBox': GUI.eraseRainbowBox.checkState(),
'disableProcessingBox': GUI.disableProcessingBox.checkState(), 'disableProcessingBox': GUI.disableProcessingBox.checkState(),
'pdfExtractBox': GUI.pdfExtractBox.checkState(),
'pdfWidthBox': GUI.pdfWidthBox.checkState(),
'noSmartCoverCropBox': GUI.noSmartCoverCropBox.checkState(),
'coverFillBox': GUI.coverFillBox.checkState(),
'metadataTitleBox': GUI.metadataTitleBox.checkState(), 'metadataTitleBox': GUI.metadataTitleBox.checkState(),
'mozJpegBox': GUI.mozJpegBox.checkState(), 'mozJpegBox': GUI.mozJpegBox.checkState(),
'forcePngRgbBox': GUI.forcePngRgbBox.checkState(),
'webpBox': GUI.webpBox.checkState(),
'pngLegacyBox': GUI.pngLegacyBox.checkState(),
'noQuantizeBox': GUI.noQuantizeBox.checkState(),
'jpegQualityBox': GUI.jpegQualityBox.checkState(),
'jpegQuality': GUI.jpegQualitySpinBox.value(),
'widthBox': GUI.widthBox.value(), 'widthBox': GUI.widthBox.value(),
'heightBox': GUI.heightBox.value(), 'heightBox': GUI.heightBox.value(),
'deleteBox': GUI.deleteBox.checkState(), 'deleteBox': GUI.deleteBox.checkState(),
'tempDirBox': GUI.tempDirBox.checkState(),
'spreadShiftBox': GUI.spreadShiftBox.checkState(), 'spreadShiftBox': GUI.spreadShiftBox.checkState(),
'fileFusionBox': GUI.fileFusionBox.checkState(), 'fileFusionBox': GUI.fileFusionBox.checkState(),
'defaultOutputFolderBox': GUI.defaultOutputFolderBox.checkState(), 'defaultOutputFolderBox': GUI.defaultOutputFolderBox.checkState(),
'noRotateBox': GUI.noRotateBox.checkState(), 'noRotateBox': GUI.noRotateBox.checkState(),
'rotateRightBox': GUI.rotateRightBox.checkState(),
'rotateFirstBox': GUI.rotateFirstBox.checkState(), 'rotateFirstBox': GUI.rotateFirstBox.checkState(),
'maximizeStrips': GUI.maximizeStrips.checkState(), 'maximizeStrips': GUI.maximizeStrips.checkState(),
'gammaSlider': float(self.gammaValue) * 100, 'gammaSlider': float(self.gammaValue) * 100,
@@ -1111,7 +1027,7 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
MW.activateWindow() MW.activateWindow()
if type(message) is bytes: if type(message) is bytes:
message = message.decode('UTF-8') message = message.decode('UTF-8')
if not self.conversionAlive and message != 'ARISE' and not GUI.jobList.findItems(message, Qt.MatchFlag.MatchExactly): if not self.conversionAlive and message != 'ARISE':
if self.needClean: if self.needClean:
self.needClean = False self.needClean = False
GUI.jobList.clear() GUI.jobList.clear()
@@ -1142,8 +1058,6 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
if message[-1] == '/': if message[-1] == '/':
message = message[:-1] message = message[:-1]
self.handleMessage(message) self.handleMessage(message)
# sorting may conflict with manual file fusion order
# GUI.jobList.sortItems()
def forceShutdown(self): def forceShutdown(self):
self.saveSettings(None) self.saveSettings(None)
@@ -1220,7 +1134,7 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
'convertButton', 'formatBox']: 'convertButton', 'formatBox']:
getattr(GUI, element).setMinimumSize(QSize(0, 0)) getattr(GUI, element).setMinimumSize(QSize(0, 0))
GUI.gridLayout.setContentsMargins(-1, -1, -1, -1) GUI.gridLayout.setContentsMargins(-1, -1, -1, -1)
for element in ['gridLayout_2', 'gridLayout_3', 'gridLayout_4', 'gridLayout_6', 'horizontalLayout_2']: for element in ['gridLayout_2', 'gridLayout_3', 'gridLayout_4', 'horizontalLayout', 'horizontalLayout_2']:
getattr(GUI, element).setContentsMargins(-1, 0, -1, 0) getattr(GUI, element).setContentsMargins(-1, 0, -1, 0)
if self.windowSize == '0x0': if self.windowSize == '0x0':
MW.resize(500, 500) MW.resize(500, 500)
@@ -1230,7 +1144,6 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
"EPUB": {'icon': 'EPUB', 'format': 'EPUB'}, "EPUB": {'icon': 'EPUB', 'format': 'EPUB'},
"CBZ": {'icon': 'CBZ', 'format': 'CBZ'}, "CBZ": {'icon': 'CBZ', 'format': 'CBZ'},
"PDF": {'icon': 'EPUB', 'format': 'PDF'}, "PDF": {'icon': 'EPUB', 'format': 'PDF'},
"PDF (200MB limit)": {'icon': 'EPUB', 'format': 'PDF-200MB'},
"KFX (does not work)": {'icon': 'KFX', 'format': 'KFX'}, "KFX (does not work)": {'icon': 'KFX', 'format': 'KFX'},
"MOBI + EPUB": {'icon': 'MOBI', 'format': 'MOBI+EPUB'}, "MOBI + EPUB": {'icon': 'MOBI', 'format': 'MOBI+EPUB'},
"EPUB (200MB limit)": {'icon': 'EPUB', 'format': 'EPUB-200MB'}, "EPUB (200MB limit)": {'icon': 'EPUB', 'format': 'EPUB-200MB'},
@@ -1247,24 +1160,9 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
'DefaultUpscale': True, 'ForceColor': False, 'Label': 'KPW34'}, 'DefaultUpscale': True, 'ForceColor': False, 'Label': 'KPW34'},
"Kindle Voyage": {'PVOptions': True, 'ForceExpert': False, 'DefaultFormat': 0, "Kindle Voyage": {'PVOptions': True, 'ForceExpert': False, 'DefaultFormat': 0,
'DefaultUpscale': True, 'ForceColor': False, 'Label': 'KV'}, 'DefaultUpscale': True, 'ForceColor': False, 'Label': 'KV'},
"Kindle 1860x1920": { "Kindle Scribe": {
'PVOptions': True, 'ForceExpert': False, 'DefaultFormat': 0, 'DefaultUpscale': False, 'ForceColor': False, 'Label': 'KS1860',
},
"Kindle 1920x1920": {
'PVOptions': True, 'ForceExpert': False, 'DefaultFormat': 0, 'DefaultUpscale': False, 'ForceColor': False, 'Label': 'KS1920',
},
"Kindle 1240x1860": {
'PVOptions': True, 'ForceExpert': False, 'DefaultFormat': 0, 'DefaultUpscale': False, 'ForceColor': False, 'Label': 'KS1240',
},
"Kindle Scribe 1/2": {
'PVOptions': True, 'ForceExpert': False, 'DefaultFormat': 0, 'DefaultUpscale': False, 'ForceColor': False, 'Label': 'KS', 'PVOptions': True, 'ForceExpert': False, 'DefaultFormat': 0, 'DefaultUpscale': False, 'ForceColor': False, 'Label': 'KS',
}, },
"Kindle Scribe 3": {
'PVOptions': True, 'ForceExpert': False, 'DefaultFormat': 3, 'DefaultUpscale': False, 'ForceColor': False, 'Label': 'KS3',
},
"Kindle Scribe Colorsoft": {
'PVOptions': True, 'ForceExpert': False, 'DefaultFormat': 3, 'DefaultUpscale': False, 'ForceColor': True, 'Label': 'KSCS',
},
"Kindle 11": { "Kindle 11": {
'PVOptions': True, 'ForceExpert': False, 'DefaultFormat': 0, 'DefaultUpscale': True, 'ForceColor': False, 'Label': 'K11', 'PVOptions': True, 'ForceExpert': False, 'DefaultFormat': 0, 'DefaultUpscale': True, 'ForceColor': False, 'Label': 'K11',
}, },
@@ -1272,7 +1170,7 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
'PVOptions': True, 'ForceExpert': False, 'DefaultFormat': 0, 'DefaultUpscale': True, 'ForceColor': False, 'Label': 'KPW5', 'PVOptions': True, 'ForceExpert': False, 'DefaultFormat': 0, 'DefaultUpscale': True, 'ForceColor': False, 'Label': 'KPW5',
}, },
"Kindle Paperwhite 12": { "Kindle Paperwhite 12": {
'PVOptions': True, 'ForceExpert': False, 'DefaultFormat': 0, 'DefaultUpscale': True, 'ForceColor': False, 'Label': 'KPW6', 'PVOptions': True, 'ForceExpert': False, 'DefaultFormat': 0, 'DefaultUpscale': True, 'ForceColor': False, 'Label': 'KO',
}, },
"Kindle Colorsoft": { "Kindle Colorsoft": {
'PVOptions': True, 'ForceExpert': False, 'DefaultFormat': 0, 'DefaultUpscale': True, 'ForceColor': True, 'Label': 'KCS', 'PVOptions': True, 'ForceExpert': False, 'DefaultFormat': 0, 'DefaultUpscale': True, 'ForceColor': True, 'Label': 'KCS',
@@ -1339,11 +1237,9 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
'Label': 'OTHER'}, 'Label': 'OTHER'},
} }
profilesGUI = [ profilesGUI = [
"Kindle Scribe Colorsoft",
"Kindle Scribe 3",
"Kindle Colorsoft", "Kindle Colorsoft",
"Kindle Paperwhite 12", "Kindle Paperwhite 12",
"Kindle Scribe 1/2", "Kindle Scribe",
"Kindle Paperwhite 11", "Kindle Paperwhite 11",
"Kindle 11", "Kindle 11",
"Kindle Oasis 9/10", "Kindle Oasis 9/10",
@@ -1363,9 +1259,6 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
"Separator", "Separator",
"Other", "Other",
"Separator", "Separator",
"Kindle 1920x1920",
"Kindle 1860x1920",
"Kindle 1240x1860",
"Kindle 8/10", "Kindle 8/10",
"Kindle Oasis 8", "Kindle Oasis 8",
"Kindle Paperwhite 7/10", "Kindle Paperwhite 7/10",
@@ -1413,7 +1306,7 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
'<a href="https://github.com/ciromattia/kcc/wiki/Important-tips">important tips</a>.', '<a href="https://github.com/ciromattia/kcc/wiki/Important-tips">important tips</a>.',
'info') 'info')
self.tar = TAR in available_archive_tools() self.tar = 'tar' in available_archive_tools()
self.sevenzip = SEVENZIP in available_archive_tools() self.sevenzip = SEVENZIP in available_archive_tools()
if not any([self.tar, self.sevenzip]): if not any([self.tar, self.sevenzip]):
self.addMessage('<a href="https://github.com/ciromattia/kcc#7-zip">Install 7z (link)</a>' self.addMessage('<a href="https://github.com/ciromattia/kcc#7-zip">Install 7z (link)</a>'
@@ -1424,19 +1317,14 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
GUI.defaultOutputFolderButton.clicked.connect(self.selectDefaultOutputFolder) GUI.defaultOutputFolderButton.clicked.connect(self.selectDefaultOutputFolder)
GUI.clearButton.clicked.connect(self.clearJobs) GUI.clearButton.clicked.connect(self.clearJobs)
GUI.fileButton.clicked.connect(self.selectFile) GUI.fileButton.clicked.connect(self.selectFile)
GUI.directoryButton.clicked.connect(self.selectDir)
GUI.editorButton.clicked.connect(self.selectFileMetaEditor) GUI.editorButton.clicked.connect(self.selectFileMetaEditor)
GUI.wikiButton.clicked.connect(self.openWiki) GUI.wikiButton.clicked.connect(self.openWiki)
GUI.kofiButton.clicked.connect(self.openKofi) GUI.kofiButton.clicked.connect(self.openKofi)
GUI.humbleButton.clicked.connect(self.openHumble)
GUI.youtubeButton.clicked.connect(self.openYouTube)
GUI.discordButton.clicked.connect(self.openDiscord)
GUI.convertButton.clicked.connect(self.convertStart) GUI.convertButton.clicked.connect(self.convertStart)
GUI.gammaSlider.valueChanged.connect(self.changeGamma) GUI.gammaSlider.valueChanged.connect(self.changeGamma)
GUI.gammaBox.stateChanged.connect(self.togglegammaBox) GUI.gammaBox.stateChanged.connect(self.togglegammaBox)
GUI.croppingBox.stateChanged.connect(self.togglecroppingBox) GUI.croppingBox.stateChanged.connect(self.togglecroppingBox)
GUI.croppingPowerSlider.valueChanged.connect(self.changeCroppingPower) GUI.croppingPowerSlider.valueChanged.connect(self.changeCroppingPower)
GUI.jpegQualityBox.stateChanged.connect(self.togglejpegqualityBox)
GUI.webtoonBox.stateChanged.connect(self.togglewebtoonBox) GUI.webtoonBox.stateChanged.connect(self.togglewebtoonBox)
GUI.qualityBox.stateChanged.connect(self.togglequalityBox) GUI.qualityBox.stateChanged.connect(self.togglequalityBox)
GUI.mozJpegBox.stateChanged.connect(self.toggleImageFormatBox) GUI.mozJpegBox.stateChanged.connect(self.toggleImageFormatBox)
@@ -1498,8 +1386,6 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
GUI.croppingPowerSlider.setValue(int(self.options[option])) GUI.croppingPowerSlider.setValue(int(self.options[option]))
self.changeCroppingPower(int(self.options[option])) self.changeCroppingPower(int(self.options[option]))
GUI.preserveMarginBox.setValue(self.options.get('preserveMarginBox', 0)) GUI.preserveMarginBox.setValue(self.options.get('preserveMarginBox', 0))
elif str(option) == "jpegQuality":
GUI.jpegQualitySpinBox.setValue(int(self.options[option]))
elif str(option) == "chunkSizeBox": elif str(option) == "chunkSizeBox":
GUI.chunkSizeBox.setValue(int(self.options[option])) GUI.chunkSizeBox.setValue(int(self.options[option]))
else: else:

File diff suppressed because it is too large Load Diff

View File

@@ -156,15 +156,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)

View File

@@ -1,4 +1,4 @@
__version__ = '9.7.2' __version__ = '9.3.0'
__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'

View File

@@ -43,12 +43,11 @@ from psutil import virtual_memory, disk_usage
from html import escape as hescape from html import escape as hescape
import pymupdf import pymupdf
from .shared import IMAGE_TYPES, getImageFileName, walkSort, walkLevel, sanitizeTrace, subprocess_run, dot_clean from .shared import getImageFileName, walkSort, walkLevel, sanitizeTrace, subprocess_run, dot_clean
from .comicarchive import SEVENZIP, available_archive_tools from .comicarchive import SEVENZIP, available_archive_tools
from . import comic2panel from . import comic2panel
from . import image from . import image
from . import comicarchive from . import comicarchive
from . import pdfjpgextract
from . import dualmetafix from . import dualmetafix
from . import metadata from . import metadata
from . import kindle from . import kindle
@@ -66,16 +65,16 @@ def main(argv=None):
parser.print_help() parser.print_help()
return 0 return 0
if sys.platform.startswith('win'): if sys.platform.startswith('win'):
sources = [source for option in options.input for source in glob(escape(option))] sources = set([source for option in options.input for source in glob(escape(option))])
else: else:
sources = options.input sources = set(options.input)
if len(sources) == 0: if len(sources) == 0:
print('No matching files found.') print('No matching files found.')
return 1 return 1
if options.filefusion: if options.filefusion:
fusion_path = makeFusion(list(sources)) fusion_path = makeFusion(list(sources))
sources.clear() sources.clear()
sources.append(fusion_path) sources.add(fusion_path)
for source in sources: for source in sources:
source = source.rstrip('\\').rstrip('/') source = source.rstrip('\\').rstrip('/')
options = copy(args) options = copy(args)
@@ -310,15 +309,6 @@ def buildOPF(dstdir, title, filelist, originalpath, cover=None):
f.writelines(["<dc:description>", hescape(options.summary), "</dc:description>\n"]) f.writelines(["<dc:description>", hescape(options.summary), "</dc:description>\n"])
for author in options.authors: for author in options.authors:
f.writelines(["<dc:creator>", hescape(author), "</dc:creator>\n"]) f.writelines(["<dc:creator>", hescape(author), "</dc:creator>\n"])
if not options.iskindle and options.series:
f.writelines(['<meta property="belongs-to-collection" id="c02">', hescape(options.series), "</meta>\n"])
f.writelines(['<meta refines="#c02" property="collection-type">', "series", "</meta>\n"])
if options.volume and options.number:
f.writelines(['<meta refines="#c02" property="group-position">', hescape(f"{options.volume}.{options.number}"), "</meta>\n"])
elif options.volume:
f.writelines(['<meta refines="#c02" property="group-position">', hescape(options.volume), "</meta>\n"])
elif options.number:
f.writelines(['<meta refines="#c02" property="group-position">', hescape(options.number), "</meta>\n"])
f.write("<meta property=\"dcterms:modified\">" + strftime("%Y-%m-%dT%H:%M:%SZ", gmtime()) + "</meta>\n") f.write("<meta property=\"dcterms:modified\">" + strftime("%Y-%m-%dT%H:%M:%SZ", gmtime()) + "</meta>\n")
if cover: if cover:
f.write("<meta name=\"cover\" content=\"cover\"/>\n") f.write("<meta name=\"cover\" content=\"cover\"/>\n")
@@ -362,8 +352,6 @@ def buildOPF(dstdir, title, filelist, originalpath, cover=None):
mt = 'image/png' mt = 'image/png'
elif '.gif' == filename[1]: elif '.gif' == filename[1]:
mt = 'image/gif' mt = 'image/gif'
elif '.webp' == filename[1]:
mt = 'image/webp'
else: else:
mt = 'image/jpeg' mt = 'image/jpeg'
f.write("<item id=\"img_" + str(uniqueid) + "\" href=\"" + folder + "/" + path[1] + "\" media-type=\"" + f.write("<item id=\"img_" + str(uniqueid) + "\" href=\"" + folder + "/" + path[1] + "\" media-type=\"" +
@@ -468,7 +456,7 @@ def buildOPF(dstdir, title, filelist, originalpath, cover=None):
"</container>"]) "</container>"])
f.close() f.close()
def buildEPUB(path, chapternames, tomenumber, ischunked, cover: image.Cover, originalpath, job_progress='', len_tomes=0): def buildEPUB(path, chapternames, tomenumber, ischunked, cover: image.Cover, originalpath, len_tomes=0):
filelist = [] filelist = []
chapterlist = [] chapterlist = []
os.mkdir(os.path.join(path, 'OEBPS', 'Text')) os.mkdir(os.path.join(path, 'OEBPS', 'Text'))
@@ -556,7 +544,7 @@ def buildEPUB(path, chapternames, tomenumber, ischunked, cover: image.Cover, ori
f.close() f.close()
build_html_start = perf_counter() build_html_start = perf_counter()
if cover: if cover:
cover.save_to_folder(os.path.join(path, 'OEBPS', 'Images', 'cover.jpg'), tomenumber, len_tomes) cover.save_to_epub(os.path.join(path, 'OEBPS', 'Images', 'cover.jpg'), tomenumber, len_tomes)
dot_clean(path) dot_clean(path)
options.covers.append((cover, options.uuid)) options.covers.append((cover, options.uuid))
for dirpath, dirnames, filenames in os.walk(os.path.join(path, 'OEBPS', 'Images')): for dirpath, dirnames, filenames in os.walk(os.path.join(path, 'OEBPS', 'Images')):
@@ -576,7 +564,7 @@ def buildEPUB(path, chapternames, tomenumber, ischunked, cover: image.Cover, ori
else: else:
filelist.append(buildHTML(dirpath, afile, os.path.join(dirpath, afile))) filelist.append(buildHTML(dirpath, afile, os.path.join(dirpath, afile)))
build_html_end = perf_counter() build_html_end = perf_counter()
print(f"{job_progress}buildHTML: {build_html_end - build_html_start} seconds") print(f"buildHTML: {build_html_end - build_html_start} seconds")
# Overwrite chapternames if ComicInfo.xml has bookmarks # Overwrite chapternames if ComicInfo.xml has bookmarks
if ischunked: if ischunked:
options.comicinfo_chapters = [] options.comicinfo_chapters = []
@@ -612,7 +600,7 @@ def buildEPUB(path, chapternames, tomenumber, ischunked, cover: image.Cover, ori
buildOPF(path, options.title, filelist, originalpath, cover) buildOPF(path, options.title, filelist, originalpath, cover)
def buildPDF(path, title, job_progress='', cover=None, output_file=None): def buildPDF(path, title, cover=None, output_file=None):
""" """
Build a PDF file from processed comic images. Build a PDF file from processed comic images.
Images are combined into a single PDF optimized for e-readers. Images are combined into a single PDF optimized for e-readers.
@@ -637,11 +625,11 @@ def buildPDF(path, title, job_progress='', cover=None, output_file=None):
# Save with optimizations for smaller file size # Save with optimizations for smaller file size
doc.save(output_file, deflate=True, garbage=4, clean=True) doc.save(output_file, deflate=True, garbage=4, clean=True)
end = perf_counter() end = perf_counter()
print(f"{job_progress}MuPDF output: {end-start} sec") print(f"MuPDF output: {end-start} sec")
return output_file return output_file
def imgDirectoryProcessing(path, job_progress=''): def imgDirectoryProcessing(path):
global workerPool, workerOutput global workerPool, workerOutput
workerPool = Pool(maxtasksperchild=100) workerPool = Pool(maxtasksperchild=100)
workerOutput = [] workerOutput = []
@@ -661,7 +649,7 @@ def imgDirectoryProcessing(path, job_progress=''):
workerPool.close() workerPool.close()
workerPool.join() workerPool.join()
img_processing_end = perf_counter() img_processing_end = perf_counter()
print(f"{job_progress}imgFileProcessing: {img_processing_end - img_processing_start} seconds") print(f"imgFileProcessing: {img_processing_end - img_processing_start} seconds")
# macOS 15 likes to add ._ files after multiprocessing # macOS 15 likes to add ._ files after multiprocessing
dot_clean(path) dot_clean(path)
@@ -671,7 +659,7 @@ def imgDirectoryProcessing(path, job_progress=''):
raise UserWarning("Conversion interrupted.") raise UserWarning("Conversion interrupted.")
if len(workerOutput) > 0: if len(workerOutput) > 0:
rmtree(os.path.join(path, '..', '..'), True) rmtree(os.path.join(path, '..', '..'), True)
raise RuntimeError("One of workers crashed. Maybe restart PC. Cause: " + workerOutput[0][0], workerOutput[0][1]) raise RuntimeError("One of workers crashed. Cause: " + workerOutput[0][0], workerOutput[0][1])
else: else:
rmtree(os.path.join(path, '..', '..'), True) rmtree(os.path.join(path, '..', '..'), True)
raise UserWarning("C2E: Source directory is empty.") raise UserWarning("C2E: Source directory is empty.")
@@ -700,6 +688,7 @@ def imgFileProcessing(work):
workImg = image.ComicPageParser((dirpath, afile), opt) workImg = image.ComicPageParser((dirpath, afile), opt)
for i in workImg.payload: for i in workImg.payload:
img = image.ComicPage(opt, *i) img = image.ComicPage(opt, *i)
is_color = (opt.forcecolor and img.color)
if opt.cropping == 2 and not opt.webtoon: if opt.cropping == 2 and not opt.webtoon:
img.cropPageNumber(opt.croppingp, opt.croppingm) img.cropPageNumber(opt.croppingp, opt.croppingm)
if opt.cropping == 1 and not opt.webtoon: if opt.cropping == 1 and not opt.webtoon:
@@ -709,24 +698,18 @@ def imgFileProcessing(work):
img.gammaCorrectImage() img.gammaCorrectImage()
if not img.colorOutput:
img.convertToGrayscale()
img.autocontrastImage() img.autocontrastImage()
img.resizeImage() img.resizeImage()
img.optimizeForDisplay(opt.eraserainbow, img.colorOutput) img.optimizeForDisplay(opt.eraserainbow, is_color)
if img.colorOutput: if is_color:
pass pass
elif opt.forcepng: elif opt.forcepng:
if not opt.noquantize: img.convertToGrayscale()
if opt.format != 'PDF':
img.quantizeImage() img.quantizeImage()
if opt.format == 'PDF': else:
img.convertToGrayscale() img.convertToGrayscale()
elif opt.profile == 'KDX' and opt.format == 'CBZ':
img.convertToGrayscale()
elif opt.pnglegacy:
img.convertToGrayscale()
output.append(img.saveToDir()) output.append(img.saveToDir())
return output return output
except Exception: except Exception:
@@ -756,9 +739,7 @@ def render_page(vector):
cpu = vector[1] # number of CPUs cpu = vector[1] # number of CPUs
filename = vector[2] # document filename filename = vector[2] # document filename
output_dir = vector[3] output_dir = vector[3]
target_width = vector[4] target_height = vector[4]
target_height = vector[5]
pdf_width = vector[6]
with pymupdf.open(filename) as doc: # open the document with pymupdf.open(filename) as doc: # open the document
num_pages = doc.page_count # get number of pages num_pages = doc.page_count # get number of pages
@@ -769,10 +750,7 @@ def render_page(vector):
for i in range(seg_from, seg_to): # work through our page segment for i in range(seg_from, seg_to): # work through our page segment
page = doc[i] page = doc[i]
if not pdf_width or page.rect.width > page.rect.height: zoom = target_height / page.rect.height
zoom = target_height / page.rect.height
else:
zoom = target_width / page.rect.width
mat = pymupdf.Matrix(zoom, zoom) mat = pymupdf.Matrix(zoom, zoom)
# TODO: decide colorspace earlier so later color check is cheaper. # TODO: decide colorspace earlier so later color check is cheaper.
# This is actually pretty hard when you have to deal with color vector text # This is actually pretty hard when you have to deal with color vector text
@@ -822,7 +800,9 @@ def extract_page(vector):
if len(image_list) > 1: if len(image_list) > 1:
raise UserWarning("mupdf_pdf_extract_page_image() function can be used only with single image pages.") raise UserWarning("mupdf_pdf_extract_page_image() function can be used only with single image pages.")
if not image_list: if not image_list:
continue width, height = int(page.rect.width), int(page.rect.height)
blank_page = Image.new("RGB", (width, height), "white")
blank_page.save(output_path)
else: else:
xref = image_list[0][0] xref = image_list[0][0]
d = doc.extract_image(xref) d = doc.extract_image(xref)
@@ -837,7 +817,7 @@ def extract_page(vector):
def mupdf_pdf_process_pages_parallel(filename, output_dir, target_width, target_height): def mupdf_pdf_process_pages_parallel(filename, output_dir, target_height):
render = False render = False
with pymupdf.open(filename) as doc: with pymupdf.open(filename) as doc:
for page in doc: for page in doc:
@@ -857,7 +837,7 @@ def mupdf_pdf_process_pages_parallel(filename, output_dir, target_width, target_
cpu = cpu_count() cpu = cpu_count()
# make vectors of arguments for the processes # make vectors of arguments for the processes
vectors = [(i, cpu, filename, output_dir, target_width, target_height, options.pdfwidth) for i in range(cpu)] vectors = [(i, cpu, filename, output_dir, target_height) for i in range(cpu)]
print("Starting %i processes for '%s'." % (cpu, filename)) print("Starting %i processes for '%s'." % (cpu, filename))
@@ -871,18 +851,14 @@ def mupdf_pdf_process_pages_parallel(filename, output_dir, target_width, target_
def getWorkFolder(afile, workdir=None): def getWorkFolder(afile):
if not workdir:
workdir = mkdtemp('', 'KCC-')
if options.tempdir:
workdir = mkdtemp('', 'KCC-', os.path.dirname(afile))
fullPath = os.path.join(workdir, 'OEBPS', 'Images')
else:
fullPath = workdir
if os.path.isdir(afile): if os.path.isdir(afile):
if disk_usage(gettempdir())[2] < getDirectorySize(afile) * 2.5: if disk_usage(gettempdir())[2] < getDirectorySize(afile) * 2.5:
raise UserWarning("Not enough disk space to perform conversion.") raise UserWarning("Not enough disk space to perform conversion.")
workdir = mkdtemp('', 'KCC-', os.path.dirname(afile))
try: try:
os.rmdir(workdir)
fullPath = os.path.join(workdir, 'OEBPS', 'Images')
copytree(afile, fullPath) copytree(afile, fullPath)
sanitizePermissions(fullPath) sanitizePermissions(fullPath)
return workdir return workdir
@@ -893,60 +869,48 @@ def getWorkFolder(afile, workdir=None):
if disk_usage(gettempdir())[2] < os.path.getsize(afile) * 2.5: if disk_usage(gettempdir())[2] < os.path.getsize(afile) * 2.5:
raise UserWarning("Not enough disk space to perform conversion.") raise UserWarning("Not enough disk space to perform conversion.")
if afile.lower().endswith('.pdf'): if afile.lower().endswith('.pdf'):
if not os.path.exists(fullPath): workdir = mkdtemp('', 'KCC-', os.path.dirname(afile))
os.makedirs(fullPath)
path = workdir path = workdir
sanitizePermissions(path) sanitizePermissions(path)
if options.pdfextract: target_height = options.profileData[1][1]
pdf = pdfjpgextract.PdfJpgExtract(afile, fullPath)
njpg = pdf.extract()
if njpg == 0:
raise UserWarning("Failed to extract images from PDF file.")
return workdir
target_width, target_height = options.profileData[1]
if options.cropping == 1: if options.cropping == 1:
target_height *= 1.2 #Account for possible margin at the top and bottom target_height = target_height + target_height*0.20 #Account for possible margin at the top and bottom
target_width *= 1.2
elif options.cropping == 2: elif options.cropping == 2:
target_height *= 1.25 #Account for possible margin at the top and bottom with page number target_height = target_height + target_height*0.25 #Account for possible margin at the top and bottom with page number
target_width *= 1.25
try: try:
mupdf_pdf_process_pages_parallel(afile, fullPath, target_width, target_height) mupdf_pdf_process_pages_parallel(afile, workdir, target_height)
except Exception as e: except Exception as e:
rmtree(path, True) rmtree(path, True)
raise UserWarning(f"Failed to extract images from PDF file. {e}") raise UserWarning(f"Failed to extract images from PDF file. {e}")
return workdir
else: else:
if not os.path.exists(fullPath): workdir = mkdtemp('', 'KCC-', os.path.dirname(afile))
os.makedirs(fullPath)
try: try:
cbx = comicarchive.ComicArchive(afile) cbx = comicarchive.ComicArchive(afile)
path = cbx.extract(fullPath) path = cbx.extract(workdir)
sanitizePermissions(path) sanitizePermissions(path)
tdir = os.listdir(fullPath) tdir = os.listdir(workdir)
if len(tdir) == 2 and 'ComicInfo.xml' in tdir: if len(tdir) == 2 and 'ComicInfo.xml' in tdir:
tdir.remove('ComicInfo.xml') tdir.remove('ComicInfo.xml')
if os.path.isdir(os.path.join(fullPath, tdir[0])): if os.path.isdir(os.path.join(workdir, tdir[0])):
os.replace( os.replace(
os.path.join(fullPath, 'ComicInfo.xml'), os.path.join(workdir, 'ComicInfo.xml'),
os.path.join(fullPath, tdir[0], 'ComicInfo.xml') os.path.join(workdir, tdir[0], 'ComicInfo.xml')
) )
if len(tdir) == 1 and os.path.isdir(os.path.join(fullPath, tdir[0])): if len(tdir) == 1 and os.path.isdir(os.path.join(workdir, tdir[0])):
for file in os.listdir(os.path.join(fullPath, tdir[0])): path = os.path.join(workdir, tdir[0])
move(os.path.join(fullPath, tdir[0], file), fullPath)
os.rmdir(os.path.join(fullPath, tdir[0]))
return workdir
except OSError as e: except OSError as e:
rmtree(workdir, True) rmtree(workdir, True)
raise UserWarning(e) raise UserWarning(e)
else: else:
raise UserWarning("Failed to open source file/directory.") raise UserWarning("Failed to open source file/directory.")
newpath = mkdtemp('', 'KCC-', os.path.dirname(afile))
os.renames(path, os.path.join(newpath, 'OEBPS', 'Images'))
return newpath
def getOutputFilename(srcpath, wantedname, ext, tomenumber): def getOutputFilename(srcpath, wantedname, ext, tomenumber):
source_path = Path(srcpath)
if srcpath[-1] == os.path.sep: if srcpath[-1] == os.path.sep:
srcpath = srcpath[:-1] srcpath = srcpath[:-1]
if 'Ko' in options.profile and options.format == 'EPUB': if 'Ko' in options.profile and options.format == 'EPUB':
@@ -956,29 +920,20 @@ def getOutputFilename(srcpath, wantedname, ext, tomenumber):
else: else:
ext = '.kepub.epub' ext = '.kepub.epub'
if wantedname is not None: if wantedname is not None:
wanted_root, wanted_ext = os.path.splitext(wantedname)
if wantedname.endswith(ext): if wantedname.endswith(ext):
filename = os.path.abspath(wantedname) filename = os.path.abspath(wantedname)
elif wanted_ext == '.mobi' and ext == '.epub': elif os.path.isdir(srcpath):
filename = os.path.abspath(wanted_root + ext) filename = os.path.join(os.path.abspath(options.output), os.path.basename(srcpath) + ext)
# output directory
else: else:
abs_path = os.path.abspath(options.output) filename = os.path.join(os.path.abspath(options.output),
if not os.path.exists(abs_path): os.path.basename(os.path.splitext(srcpath)[0]) + ext)
os.mkdir(abs_path)
if source_path.is_file():
filename = os.path.join(os.path.abspath(options.output), source_path.stem + tomenumber + ext)
else:
filename = os.path.join(os.path.abspath(options.output), source_path.name + tomenumber + ext)
elif os.path.isdir(srcpath): elif os.path.isdir(srcpath):
filename = srcpath + tomenumber + ext filename = srcpath + tomenumber + ext
else: else:
if 'Ko' in options.profile and options.format == 'EPUB': if 'Ko' in options.profile and options.format == 'EPUB':
if source_path.is_file(): src = pathlib.Path(srcpath)
name = re.sub(r'\W+', '_', source_path.stem) + tomenumber + ext name = re.sub(r'\W+', '_', src.stem) + tomenumber + ext
else: filename = src.with_name(name)
name = re.sub(r'\W+', '_', source_path.name) + tomenumber + ext
filename = source_path.with_name(name)
else: else:
filename = os.path.splitext(srcpath)[0] + tomenumber + ext filename = os.path.splitext(srcpath)[0] + tomenumber + ext
if os.path.isfile(filename): if os.path.isfile(filename):
@@ -987,13 +942,6 @@ def getOutputFilename(srcpath, wantedname, ext, tomenumber):
while os.path.isfile(basename + '_kcc' + str(counter) + ext): while os.path.isfile(basename + '_kcc' + str(counter) + ext):
counter += 1 counter += 1
filename = basename + '_kcc' + str(counter) + ext filename = basename + '_kcc' + str(counter) + ext
elif options.format == 'MOBI' and ext == '.epub':
counter = 0
basename = os.path.splitext(filename)[0]
if os.path.isfile(basename + '.mobi'):
while os.path.isfile(basename + '_kcc' + str(counter) + '.mobi'):
counter += 1
filename = basename + '_kcc' + str(counter) + ext
return filename return filename
@@ -1002,9 +950,6 @@ def getMetadata(path, originalpath):
options.comicinfo_chapters = [] options.comicinfo_chapters = []
options.summary = '' options.summary = ''
titleSuffix = '' titleSuffix = ''
options.volume = ''
options.number = ''
options.series = ''
if options.title == 'defaulttitle': if options.title == 'defaulttitle':
defaultTitle = True defaultTitle = True
if os.path.isdir(originalpath): if os.path.isdir(originalpath):
@@ -1033,10 +978,8 @@ def getMetadata(path, originalpath):
options.title = xml.data['Series'] options.title = xml.data['Series']
if xml.data['Volume']: if xml.data['Volume']:
titleSuffix += ' Vol. ' + xml.data['Volume'].zfill(2) titleSuffix += ' Vol. ' + xml.data['Volume'].zfill(2)
options.volume = xml.data['Volume']
if xml.data['Number']: if xml.data['Number']:
titleSuffix += ' #' + xml.data['Number'].zfill(3) titleSuffix += ' #' + xml.data['Number'].zfill(3)
options.number = xml.data['Number']
if options.metadatatitle == 1 and xml.data['Title']: if options.metadatatitle == 1 and xml.data['Title']:
titleSuffix += ': ' + xml.data['Title'] titleSuffix += ': ' + xml.data['Title']
options.title += titleSuffix options.title += titleSuffix
@@ -1054,8 +997,6 @@ def getMetadata(path, originalpath):
options.comicinfo_chapters = xml.data['Bookmarks'] options.comicinfo_chapters = xml.data['Bookmarks']
if xml.data['Summary']: if xml.data['Summary']:
options.summary = xml.data['Summary'] options.summary = xml.data['Summary']
if xml.data['Series']:
options.series = xml.data['Series']
os.remove(xmlPath) os.remove(xmlPath)
if originalpath.lower().endswith('.pdf'): if originalpath.lower().endswith('.pdf'):
@@ -1098,7 +1039,7 @@ def removeNonImages(filetree):
for root, dirs, files in os.walk(filetree): for root, dirs, files in os.walk(filetree):
for name in files: for name in files:
_, ext = getImageFileName(name) _, ext = getImageFileName(name)
if ext not in IMAGE_TYPES: if ext not in ('.png', '.jpg', '.jpeg', '.gif', '.webp', '.jp2', '.avif'):
if os.path.exists(os.path.join(root, name)): if os.path.exists(os.path.join(root, name)):
os.remove(os.path.join(root, name)) os.remove(os.path.join(root, name))
# remove empty nested folders # remove empty nested folders
@@ -1110,7 +1051,7 @@ def removeNonImages(filetree):
raise UserWarning('No images detected, nested archives are not supported.') raise UserWarning('No images detected, nested archives are not supported.')
def sanitizeTree(filetree, prefix='kcc'): def sanitizeTree(filetree):
chapterNames = {} chapterNames = {}
page = 1 page = 1
cover_path = None cover_path = None
@@ -1120,7 +1061,7 @@ def sanitizeTree(filetree, prefix='kcc'):
_, ext = getImageFileName(name) _, ext = getImageFileName(name)
# 9999 page limit # 9999 page limit
unique_name = f'{prefix}-{page:04}' unique_name = f'kcc-{page:04}'
page += 1 page += 1
newKey = os.path.join(root, unique_name + ext) newKey = os.path.join(root, unique_name + ext)
@@ -1169,7 +1110,7 @@ def chunk_directory(path):
for root, _, files in os.walk(os.path.join(path, 'OEBPS', 'Images')): for root, _, files in os.walk(os.path.join(path, 'OEBPS', 'Images')):
for f in files: for f in files:
# Windows MAX_LEN = 260 plus some buffer # Windows MAX_LEN = 260 plus some buffer
if os.name == 'nt' and len(os.path.join(root, f)) > 220: if os.name == 'nt' and len(os.path.join(root, f)) > 180:
flattenTree(os.path.join(path, 'OEBPS', 'Images')) flattenTree(os.path.join(path, 'OEBPS', 'Images'))
level = 1 level = 1
break break
@@ -1281,7 +1222,7 @@ def detectSuboptimalProcessing(tmppath, orgpath):
GUI.addMessage.emit('Source files are probably created by KCC. The second conversion will decrease quality.' GUI.addMessage.emit('Source files are probably created by KCC. The second conversion will decrease quality.'
, 'warning', False) , 'warning', False)
GUI.addMessage.emit('', '', False) GUI.addMessage.emit('', '', False)
if imageSmaller > imageNumber * 0.25 and not options.upscale and not options.stretch and not options.profile.startswith('KS'): if imageSmaller > imageNumber * 0.25 and not options.upscale and not options.stretch and options.profile != 'KS':
print("WARNING: More than 25% of images are smaller than target device resolution. " print("WARNING: More than 25% of images are smaller than target device resolution. "
"Consider enabling stretching or upscaling to improve readability.") "Consider enabling stretching or upscaling to improve readability.")
if GUI: if GUI:
@@ -1308,20 +1249,17 @@ def slugify(value, is_natural_sorted):
value = sub(r'0*([0-9]{4,})', r'\1', sub(r'([0-9]+)', r'0000\1', value, count=2)) value = sub(r'0*([0-9]{4,})', r'\1', sub(r'([0-9]+)', r'0000\1', value, count=2))
return value return value
def makeZIP(zipfilename, basedir, job_progress='', isepub=False): def makeZIP(zipfilename, basedir, isepub=False):
start = perf_counter() start = perf_counter()
zipfilename = os.path.abspath(zipfilename) + '.zip' zipfilename = os.path.abspath(zipfilename) + '.zip'
if SEVENZIP in available_archive_tools(): if SEVENZIP in available_archive_tools():
if isepub: if isepub:
mimetypeFile = open(os.path.join(basedir, '!mimetype'), 'w') mimetypeFile = open(os.path.join(basedir, 'mimetype'), 'w')
mimetypeFile.write('application/epub+zip') mimetypeFile.write('application/epub+zip')
mimetypeFile.close() mimetypeFile.close()
subprocess_run([SEVENZIP, 'a', '-mx0', '-tzip', zipfilename, "*"], capture_output=True, check=True, cwd=basedir) subprocess_run([SEVENZIP, 'a', '-tzip', zipfilename, "*"], capture_output=True, check=True, cwd=basedir)
# crazy hack to ensure mimetype is first when using 7zip
if isepub:
subprocess_run([SEVENZIP, 'rn', zipfilename, '!mimetype', 'mimetype'], capture_output=True, check=True, cwd=basedir)
else: else:
zipOutput = ZipFile(zipfilename, 'w', ZIP_STORED) zipOutput = ZipFile(zipfilename, 'w', ZIP_DEFLATED)
if isepub: if isepub:
zipOutput.writestr('mimetype', 'application/epub+zip', ZIP_STORED) zipOutput.writestr('mimetype', 'application/epub+zip', ZIP_STORED)
for dirpath, _, filenames in os.walk(basedir): for dirpath, _, filenames in os.walk(basedir):
@@ -1332,7 +1270,7 @@ def makeZIP(zipfilename, basedir, job_progress='', isepub=False):
zipOutput.write(path, aPath) zipOutput.write(path, aPath)
zipOutput.close() zipOutput.close()
end = perf_counter() end = perf_counter()
print(f"{job_progress}makeZIP time: {end - start} seconds") print(f"makeZIP time: {end - start} seconds")
return zipfilename return zipfilename
def makeParser(): def makeParser():
@@ -1384,21 +1322,11 @@ def makeParser():
help="Shift first page to opposite side in landscape for spread alignment") help="Shift first page to opposite side in landscape for spread alignment")
output_options.add_argument("--norotate", action="store_true", dest="norotate", default=False, output_options.add_argument("--norotate", action="store_true", dest="norotate", default=False,
help="Do not rotate double page spreads in spread splitter option.") help="Do not rotate double page spreads in spread splitter option.")
output_options.add_argument("--rotateright", action="store_true", dest="rotateright", default=False,
help="Rotate double page spreads in opposite direction.")
output_options.add_argument("--rotatefirst", action="store_true", dest="rotatefirst", default=False, output_options.add_argument("--rotatefirst", action="store_true", dest="rotatefirst", default=False,
help="Put rotated 2 page spread first in spread splitter option.") help="Put rotated 2 page spread first in spread splitter option.")
processing_options.add_argument("-n", "--noprocessing", action="store_true", dest="noprocessing", default=False, processing_options.add_argument("-n", "--noprocessing", action="store_true", dest="noprocessing", default=False,
help="Do not modify image and ignore any profile or processing option") help="Do not modify image and ignore any profile or processing option")
processing_options.add_argument("--pdfextract", action="store_true", dest="pdfextract", default=False,
help="Use the legacy PDF image extraction method from KCC 8 and earlier")
processing_options.add_argument("--pdfwidth", action="store_true", dest="pdfwidth", default=False,
help="Render vector PDFs to device width instead of height.")
processing_options.add_argument("--nosmartcovercrop", action="store_true", dest="nosmartcovercrop", default=False,
help="Disable attempt to crop main cover from wide image")
processing_options.add_argument("--coverfill", action="store_true", dest="coverfill", default=False,
help="Crop cover to fill screen")
processing_options.add_argument("-u", "--upscale", action="store_true", dest="upscale", default=False, processing_options.add_argument("-u", "--upscale", action="store_true", dest="upscale", default=False,
help="Resize images smaller than device's resolution") help="Resize images smaller than device's resolution")
processing_options.add_argument("-s", "--stretch", action="store_true", dest="stretch", default=False, processing_options.add_argument("-s", "--stretch", action="store_true", dest="stretch", default=False,
@@ -1434,25 +1362,13 @@ def makeParser():
output_options.add_argument("--eraserainbow", action="store_true", dest="eraserainbow", default=False, output_options.add_argument("--eraserainbow", action="store_true", dest="eraserainbow", default=False,
help="Erase rainbow effect on color eink screen by attenuating interfering frequencies") help="Erase rainbow effect on color eink screen by attenuating interfering frequencies")
processing_options.add_argument("--forcepng", action="store_true", dest="forcepng", default=False, processing_options.add_argument("--forcepng", action="store_true", dest="forcepng", default=False,
help="Create PNG files instead JPEG for black and white images") help="Create PNG files instead JPEG")
processing_options.add_argument("--force-png-rgb", action="store_true", dest="force_png_rgb", default=False,
help="Force color images to be saved as PNG")
processing_options.add_argument("--webp", action="store_true", dest="webp", default=False,
help="Replace JPG with lossy WEBP and PNG with lossless WEBP")
processing_options.add_argument("--pnglegacy", action="store_true", dest="pnglegacy", default=False,
help="Use a more compatible 8 bit png instead of 4 bit")
processing_options.add_argument("--noquantize", action="store_true", dest="noquantize", default=False,
help="Don't quantize to 16 color PNG")
processing_options.add_argument("--mozjpeg", action="store_true", dest="mozjpeg", default=False, processing_options.add_argument("--mozjpeg", action="store_true", dest="mozjpeg", default=False,
help="Create JPEG files using mozJpeg") help="Create JPEG files using mozJpeg")
processing_options.add_argument("--jpeg-quality", type=int, dest="jpegquality",
help="The JPEG quality, on a scale from 0 (worst) to 95 (best). Default 85 for most devices.")
processing_options.add_argument("--maximizestrips", action="store_true", dest="maximizestrips", default=False, processing_options.add_argument("--maximizestrips", action="store_true", dest="maximizestrips", default=False,
help="Turn 1x4 strips to 2x2 strips") help="Turn 1x4 strips to 2x2 strips")
processing_options.add_argument("-d", "--delete", action="store_true", dest="delete", default=False, processing_options.add_argument("-d", "--delete", action="store_true", dest="delete", default=False,
help="Delete source file(s) or a directory. It's not recoverable.") help="Delete source file(s) or a directory. It's not recoverable.")
processing_options.add_argument("--tempdir", action="store_true", dest="tempdir", default=False,
help="Create temporary files directory on source file drive.")
custom_profile_options.add_argument("--customwidth", type=int, dest="customwidth", default=0, custom_profile_options.add_argument("--customwidth", type=int, dest="customwidth", default=0,
help="Replace screen width provided by device profile") help="Replace screen width provided by device profile")
@@ -1471,11 +1387,6 @@ def checkOptions(options):
options.isKobo = False options.isKobo = False
options.bordersColor = None options.bordersColor = None
options.keep_epub = False options.keep_epub = False
if options.format == 'PDF-200MB':
options.targetsize = 195
options.format = 'PDF'
if options.batchsplit != 2:
options.batchsplit = 1
if options.format == 'EPUB-200MB': if options.format == 'EPUB-200MB':
options.targetsize = 195 options.targetsize = 195
options.format = 'EPUB' options.format = 'EPUB'
@@ -1487,8 +1398,6 @@ def checkOptions(options):
options.format = 'MOBI' options.format = 'MOBI'
if options.batchsplit != 2: if options.batchsplit != 2:
options.batchsplit = 1 options.batchsplit = 1
if not options.targetsize and options.profile.startswith('Rmk'):
options.targetsize = 95
if options.format == 'MOBI+EPUB': if options.format == 'MOBI+EPUB':
options.keep_epub = True options.keep_epub = True
options.format = 'MOBI' options.format = 'MOBI'
@@ -1534,6 +1443,9 @@ def checkOptions(options):
if 'Ko' in options.profile: if 'Ko' in options.profile:
options.panelview = False options.panelview = False
options.hq = False options.hq = False
# CBZ files on Kindle DX/DXG support higher resolution
if options.profile == 'KDX' and options.format == 'CBZ':
options.customheight = 1200
# KFX output create EPUB that might be can be by jhowell KFX Output Calibre plugin # KFX output create EPUB that might be can be by jhowell KFX Output Calibre plugin
if options.format == 'KFX': if options.format == 'KFX':
options.format = 'EPUB' options.format = 'EPUB'
@@ -1552,26 +1464,6 @@ def checkOptions(options):
image.ProfileData.Profiles["Custom"] = newProfile image.ProfileData.Profiles["Custom"] = newProfile
options.profile = "Custom" options.profile = "Custom"
options.profileData = image.ProfileData.Profiles[options.profile] options.profileData = image.ProfileData.Profiles[options.profile]
if not options.jpegquality:
if options.profile.startswith('KS') or options.profile == 'KCS':
options.jpegquality = 90
else:
options.jpegquality = 85
options.kindle_azw3 = options.iskindle and ('MOBI' in options.format or 'EPUB' in options.format)
options.kindle_scribe_azw3 = options.profile.startswith('KS') and options.kindle_azw3
options.webp_output = options.format != 'PDF' and not options.kindle_azw3 and options.webp
# CBZ files on Kindle DX/DXG support higher resolution
if options.profile == 'KDX' and options.format == 'CBZ':
options.profileData = list(image.ProfileData.Profiles[options.profile])
options.profileData[1] = list(options.profileData[1])
options.profileData[1][1] = 1200
if options.kindle_scribe_azw3:
options.profileData = list(image.ProfileData.Profiles[options.profile])
options.profileData[1] = list(options.profileData[1])
options.profileData[1][0] = min(1920, options.profileData[1][0])
return options return options
@@ -1619,28 +1511,21 @@ def makeFusion(sources: List[str]):
fusion_path = first_path.parent.joinpath(first_path.name + ' [fused]') fusion_path = first_path.parent.joinpath(first_path.name + ' [fused]')
print("Running Fusion") print("Running Fusion")
# Check if prefix is needed when user-specified ordering differs from OS natural sorting for source in sources:
path_names = [Path(s).stem if Path(s).is_file() else Path(s).name for s in sources]
needs_prefix = os_sorted(path_names) != path_names
for index, source in enumerate(sources, start=1):
print(f"Processing {source}...") print(f"Processing {source}...")
checkPre(source) checkPre(source)
print("Checking images...") print("Checking images...")
source_path = Path(source) path = getWorkFolder(source)
# Add the fusion_0001_ prefix to maintain user-specified order if needed pathfinder = os.path.join(path, "OEBPS", "Images")
prefix = '' sanitizeTree(pathfinder)
if needs_prefix:
prefix = f'fusion_{index:04d}_'
if source_path.is_file():
targetpath = fusion_path.joinpath(f'{prefix}{source_path.stem}')
else:
targetpath = fusion_path.joinpath(f'{prefix}{source_path.name}')
getWorkFolder(source, str(targetpath))
sanitizeTree(targetpath, prefix='fusion')
# TODO: remove flattenTree when subchapters are supported # TODO: remove flattenTree when subchapters are supported
flattenTree(targetpath) flattenTree(pathfinder)
source_path = Path(source)
if source_path.is_file():
os.renames(pathfinder, fusion_path.joinpath(source_path.stem))
else:
os.renames(pathfinder, fusion_path.joinpath(source_path.name))
end = perf_counter() end = perf_counter()
print(f"makefusion: {end - start} seconds") print(f"makefusion: {end - start} seconds")
@@ -1649,7 +1534,7 @@ def makeFusion(sources: List[str]):
return str(fusion_path) return str(fusion_path)
def makeBook(source, qtgui=None, job_progress=''): def makeBook(source, qtgui=None):
start = perf_counter() start = perf_counter()
global GUI global GUI
GUI = qtgui GUI = qtgui
@@ -1657,31 +1542,29 @@ def makeBook(source, qtgui=None, job_progress=''):
GUI.progressBarTick.emit('1') GUI.progressBarTick.emit('1')
else: else:
checkTools(source) checkTools(source)
options.kindle_scribe_azw3 = options.profile == 'KS' and ('MOBI' in options.format or 'EPUB' in options.format)
checkPre(source) checkPre(source)
print(f"{job_progress}Preparing source images...") print("Preparing source images...")
path = getWorkFolder(source) path = getWorkFolder(source)
print(f"{job_progress}Checking images...") print("Checking images...")
getMetadata(os.path.join(path, "OEBPS", "Images"), source) getMetadata(os.path.join(path, "OEBPS", "Images"), source)
removeNonImages(os.path.join(path, "OEBPS", "Images")) removeNonImages(os.path.join(path, "OEBPS", "Images"))
detectSuboptimalProcessing(os.path.join(path, "OEBPS", "Images"), source) detectSuboptimalProcessing(os.path.join(path, "OEBPS", "Images"), source)
chapterNames, cover_path = sanitizeTree(os.path.join(path, 'OEBPS', 'Images')) chapterNames, cover_path = sanitizeTree(os.path.join(path, 'OEBPS', 'Images'))
if options.filefusion:
# Strip the fusion_0001_ sort prefix from makeFusion if present
chapterNames = {k: sub(r'^fusion_\d{4}_', '', v) for k, v in chapterNames.items()}
cover = None cover = None
if not options.webtoon: if not options.webtoon:
cover = image.Cover(cover_path, options) cover = image.Cover(cover_path, options)
if options.webtoon: if options.webtoon:
x, y = image.ProfileData.Profiles[options.profile][1] x, y = image.ProfileData.Profiles[options.profile][1]
comic2panel.main(['-y ' + str(y), '-x' + str(x), '-i', '-m', path], job_progress, qtgui) comic2panel.main(['-y ' + str(y), '-x' + str(x), '-i', '-m', path], qtgui)
if options.noprocessing: if options.noprocessing:
print(f"{job_progress}Do not process image, ignore any profile or processing option") print("Do not process image, ignore any profile or processing option")
else: else:
print(f"{job_progress}Processing images...") print("Processing images...")
if GUI: if GUI:
GUI.progressBarTick.emit(f'{job_progress}Processing images') GUI.progressBarTick.emit('Processing images')
imgDirectoryProcessing(os.path.join(path, "OEBPS", "Images"), job_progress) imgDirectoryProcessing(os.path.join(path, "OEBPS", "Images"))
if GUI: if GUI:
GUI.progressBarTick.emit('1') GUI.progressBarTick.emit('1')
if options.batchsplit > 0 or options.targetsize: if options.batchsplit > 0 or options.targetsize:
@@ -1692,11 +1575,11 @@ def makeBook(source, qtgui=None, job_progress=''):
tomeNumber = 0 tomeNumber = 0
if GUI: if GUI:
if options.format == 'CBZ': if options.format == 'CBZ':
GUI.progressBarTick.emit(f'{job_progress}Compressing CBZ files') GUI.progressBarTick.emit('Compressing CBZ files')
elif options.format == 'PDF': elif options.format == 'PDF':
GUI.progressBarTick.emit(f'{job_progress}Creating PDF files') GUI.progressBarTick.emit('Creating PDF files')
else: else:
GUI.progressBarTick.emit(f'{job_progress}Compressing EPUB files') GUI.progressBarTick.emit('Compressing EPUB files')
GUI.progressBarTick.emit(str(len(tomes) + 1)) GUI.progressBarTick.emit(str(len(tomes) + 1))
GUI.progressBarTick.emit('tick') GUI.progressBarTick.emit('tick')
options.baseTitle = options.title options.baseTitle = options.title
@@ -1710,33 +1593,29 @@ def makeBook(source, qtgui=None, job_progress=''):
tomeNumber += 1 tomeNumber += 1
options.title = options.baseTitle + ' [' + str(tomeNumber) + '/' + str(len(tomes)) + ']' options.title = options.baseTitle + ' [' + str(tomeNumber) + '/' + str(len(tomes)) + ']'
if options.format == 'CBZ': if options.format == 'CBZ':
print(f"{job_progress}Creating CBZ file...") print("Creating CBZ file...")
if len(tomes) > 1: if len(tomes) > 1:
filepath.append(getOutputFilename(source, options.output, '.cbz', ' ' + str(tomeNumber))) filepath.append(getOutputFilename(source, options.output, '.cbz', ' ' + str(tomeNumber)))
else: else:
filepath.append(getOutputFilename(source, options.output, '.cbz', '')) filepath.append(getOutputFilename(source, options.output, '.cbz', ''))
if cover.smartcover: makeZIP(tome + '_comic', os.path.join(tome, "OEBPS", "Images"))
cover.save_to_folder(os.path.join(tome, 'OEBPS', 'Images', 'cover.jpg'), tomeNumber, len(tomes))
makeZIP(tome + '_comic', os.path.join(tome, "OEBPS", "Images"), job_progress)
elif options.format == 'PDF': elif options.format == 'PDF':
print(f"{job_progress}Creating PDF file with PyMuPDF...") print("Creating PDF file with PyMuPDF...")
# determine output filename based on source and tome count # determine output filename based on source and tome count
suffix = (' ' + str(tomeNumber)) if len(tomes) > 1 else '' suffix = (' ' + str(tomeNumber)) if len(tomes) > 1 else ''
output_file = getOutputFilename(source, options.output, '.pdf', suffix) output_file = getOutputFilename(source, options.output, '.pdf', suffix)
if cover.smartcover:
cover.save_to_folder(os.path.join(tome, 'OEBPS', 'Images', 'cover.jpg'), tomeNumber, len(tomes))
# use optimized buildPDF logic with streaming and compression # use optimized buildPDF logic with streaming and compression
output_pdf = buildPDF(tome, options.title, job_progress, None, output_file) output_pdf = buildPDF(tome, options.title, None, output_file)
filepath.append(output_pdf) filepath.append(output_pdf)
else: else:
print(f"{job_progress}Creating EPUB file...") print("Creating EPUB file...")
if len(tomes) > 1: if len(tomes) > 1:
buildEPUB(tome, chapterNames, tomeNumber, True, cover, source, job_progress, len(tomes)) buildEPUB(tome, chapterNames, tomeNumber, True, cover, source, len(tomes))
filepath.append(getOutputFilename(source, options.output, '.epub', ' ' + str(tomeNumber))) filepath.append(getOutputFilename(source, options.output, '.epub', ' ' + str(tomeNumber)))
else: else:
buildEPUB(tome, chapterNames, tomeNumber, False, cover, source, job_progress) buildEPUB(tome, chapterNames, tomeNumber, False, cover, source)
filepath.append(getOutputFilename(source, options.output, '.epub', '')) filepath.append(getOutputFilename(source, options.output, '.epub', ''))
makeZIP(tome + '_comic', tome, job_progress, True) makeZIP(tome + '_comic', tome, True)
# Copy files to final destination (PDF files are already saved directly) # Copy files to final destination (PDF files are already saved directly)
if options.format != 'PDF': if options.format != 'PDF':
copyfile(tome + '_comic.zip', filepath[-1]) copyfile(tome + '_comic.zip', filepath[-1])
@@ -1749,23 +1628,23 @@ def makeBook(source, qtgui=None, job_progress=''):
if GUI: if GUI:
GUI.progressBarTick.emit('tick') GUI.progressBarTick.emit('tick')
if not GUI and options.format == 'MOBI': if not GUI and options.format == 'MOBI':
print(f"{job_progress}Creating MOBI files...") print("Creating MOBI files...")
work = [] work = []
for i in filepath: for i in filepath:
work.append([i]) work.append([i])
output = makeMOBI(work, GUI) output = makeMOBI(work, GUI)
for errors in output: for errors in output:
if errors[0] != 0: if errors[0] != 0:
print(f"{job_progress}Error: KindleGen failed to create MOBI!") print('Error: KindleGen failed to create MOBI!')
print(errors) print(errors)
return filepath return filepath
k = kindle.Kindle(options.profile) k = kindle.Kindle(options.profile)
if k.path and k.coverSupport: if k.path and k.coverSupport:
print(f"{job_progress}Kindle detected. Uploading covers...") print("Kindle detected. Uploading covers...")
for i in filepath: for i in filepath:
output = makeMOBIFix(i, options.covers[filepath.index(i)][1]) output = makeMOBIFix(i, options.covers[filepath.index(i)][1])
if not output[0]: if not output[0]:
print(f'{job_progress}Error: Failed to tweak KindleGen output!') print('Error: Failed to tweak KindleGen output!')
return filepath return filepath
else: else:
os.remove(i.replace('.epub', '.mobi') + '_toclean') os.remove(i.replace('.epub', '.mobi') + '_toclean')
@@ -1778,7 +1657,7 @@ def makeBook(source, qtgui=None, job_progress=''):
rmtree(source, True) rmtree(source, True)
end = perf_counter() end = perf_counter()
print(f"{job_progress}makeBook: {end - start} seconds") print(f"makeBook: {end - start} seconds")
# Clean up temporary workspace # Clean up temporary workspace
try: try:
rmtree(path, True) rmtree(path, True)
@@ -1863,3 +1742,4 @@ def makeMOBI(work, qtgui=None):
makeMOBIWorkerPool.close() makeMOBIWorkerPool.close()
makeMOBIWorkerPool.join() makeMOBIWorkerPool.join()
return makeMOBIWorkerOutput return makeMOBIWorkerOutput

View File

@@ -62,19 +62,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 * 3:
raise RuntimeError(f'Image too tall at {targetHeight} pixels. {targetWidth} pixels wide. Try using separate chapter folders or file fusion.') raise RuntimeError(f'Image too tall at {targetHeight} pixels. {targetWidth} pixels wide. Try using separate chapter folders or file fusion.')
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')
@@ -222,7 +221,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)
@@ -254,14 +253,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)
os.renames(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 +274,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,7 +287,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) 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:
@@ -296,7 +297,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:
@@ -312,6 +313,8 @@ 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:
os.renames(targetDir, sourceDir)
else: else:
rmtree(targetDir, True) rmtree(targetDir, True)
raise UserWarning("C2P: Source directory is empty.") raise UserWarning("C2P: Source directory is empty.")

View File

@@ -20,17 +20,15 @@
from functools import cached_property, lru_cache from functools import cached_property, lru_cache
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' SEVENZIP = '7zz' if platform.system() == 'Darwin' else '7z'
TAR = 'bsdtar' if platform.system() == 'Linux' else 'tar'
class ComicArchive: class ComicArchive:
@@ -67,14 +65,11 @@ 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.basename, '-C', targetdir],
[SEVENZIP, 'x', '-y', '-xr!__MACOSX', '-xr!.DS_Store', '-xr!thumbs.db', '-xr!Thumbs.db', '-o' + targetdir, self.basename], [SEVENZIP, 'x', '-y', '-xr!__MACOSX', '-xr!.DS_Store', '-xr!thumbs.db', '-xr!Thumbs.db', '-o' + targetdir, self.basename],
] ]
@@ -126,7 +121,7 @@ class ComicArchive:
def available_archive_tools(): def available_archive_tools():
available = [] available = []
for tool in [TAR, SEVENZIP, 'unar', 'unrar']: for tool in ['tar', SEVENZIP, 'unar', 'unrar']:
try: try:
subprocess_run([tool], stdout=PIPE, stderr=STDOUT) subprocess_run([tool], stdout=PIPE, stderr=STDOUT)
available.append(tool) available.append(tool)

View File

@@ -86,9 +86,6 @@ class ProfileData:
] ]
ProfilesKindleEBOK = { ProfilesKindleEBOK = {
}
ProfilesKindlePDOC = {
'K1': ("Kindle 1", (600, 670), Palette4, 1.0), 'K1': ("Kindle 1", (600, 670), Palette4, 1.0),
'K2': ("Kindle 2", (600, 670), Palette15, 1.0), 'K2': ("Kindle 2", (600, 670), Palette15, 1.0),
'KDX': ("Kindle DX/DXG", (824, 1000), Palette16, 1.0), 'KDX': ("Kindle DX/DXG", (824, 1000), Palette16, 1.0),
@@ -96,19 +93,16 @@ class ProfileData:
'K57': ("Kindle 5/7", (600, 800), Palette16, 1.0), 'K57': ("Kindle 5/7", (600, 800), Palette16, 1.0),
'KPW': ("Kindle Paperwhite 1/2", (758, 1024), Palette16, 1.0), 'KPW': ("Kindle Paperwhite 1/2", (758, 1024), Palette16, 1.0),
'KV': ("Kindle Voyage", (1072, 1448), Palette16, 1.0), 'KV': ("Kindle Voyage", (1072, 1448), Palette16, 1.0),
}
ProfilesKindlePDOC = {
'KPW34': ("Kindle Paperwhite 3/4/Oasis", (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), 'K810': ("Kindle 8/10", (600, 800), Palette16, 1.0),
'KO': ("Kindle Oasis 2/3", (1264, 1680), Palette16, 1.0), 'KO': ("Kindle Oasis 2/3/Paperwhite 12", (1264, 1680), Palette16, 1.0),
'K11': ("Kindle 11", (1072, 1448), Palette16, 1.0), 'K11': ("Kindle 11", (1072, 1448), Palette16, 1.0),
'KPW5': ("Kindle Paperwhite 5/Signature Edition", (1236, 1648), Palette16, 1.0), 'KPW5': ("Kindle Paperwhite 5/Signature Edition", (1236, 1648), Palette16, 1.0),
'KPW6': ("Kindle Paperwhite 6", (1272, 1696), Palette16, 1.0), 'KS': ("Kindle Scribe", (1860, 2480), Palette16, 1.0),
'KS1860': ("Kindle 1860", (1860, 1920), Palette16, 1.0), 'KCS': ("Kindle Colorsoft", (1264, 1680), 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", (1272, 1696), Palette16, 1.0),
'KS3': ("Kindle Scribe 3", (1986, 2648), Palette16, 1.0),
'KSCS': ("Kindle Scribe Colorsoft", (1986, 2648), Palette16, 1.0),
} }
ProfilesKindle = { ProfilesKindle = {
@@ -159,7 +153,7 @@ 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() Image.open(srcImgPath).verify()
with Image.open(srcImgPath) as im: with Image.open(srcImgPath) as im:
self.image = im.copy() self.image = im.copy()
@@ -198,13 +192,10 @@ class ComicPageParser:
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)
else:
spread = spread.rotate(-90, Image.Resampling.BICUBIC, True)
self.payload.append(['R', self.source, spread, self.fill]) 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 and width / height < 2: if self.opt.splitter != 1:
if width > height: if width > height:
leftbox = (0, 0, int(width / 2), height) leftbox = (0, 0, int(width / 2), height)
rightbox = (int(width / 2), 0, width, height) rightbox = (int(width / 2), 0, width, height)
@@ -219,13 +210,10 @@ class ComicPageParser:
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.fill])
self.payload.append(['S2', self.source, pagetwo, self.fill]) self.payload.append(['S2', self.source, pagetwo, self.fill])
if self.opt.splitter > 0 or (self.opt.splitter == 0 and width / height >= 2): 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)
else:
spread = spread.rotate(-90, Image.Resampling.BICUBIC, True)
self.payload.append(['R', self.source, spread, self.fill]) 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.fill])
@@ -275,11 +263,10 @@ class ComicPage:
_, 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.kindle_scribe_azw3 = (options.profile == 'KS') and (options.format in ('MOBI', 'EPUB'))
self.original_color_mode = image.mode self.original_color_mode = image.mode
# TODO: color check earlier # TODO: color check earlier
self.image = image.convert("RGB") self.image = image.convert("RGB")
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])
@@ -298,7 +285,8 @@ class ComicPage:
if not hasattr(Image, 'Resampling'): if not hasattr(Image, 'Resampling'):
Image.Resampling = Image Image.Resampling = Image
def colorCheck(self): @cached_property
def color(self):
if self.original_color_mode in ("L", "1"): if self.original_color_mode in ("L", "1"):
return False return False
if self.opt.webtoon: if self.opt.webtoon:
@@ -407,32 +395,25 @@ class ComicPage:
raise RuntimeError('Cannot save image. ' + str(err)) raise RuntimeError('Cannot save image. ' + str(err))
def save_with_codec(self, image, targetPath): def save_with_codec(self, image, targetPath):
if self.opt.forcepng and (not self.colorOutput or self.opt.force_png_rgb): if self.opt.forcepng:
image.info.pop('transparency', None) image.info.pop('transparency', None)
if self.opt.webp_output: if self.opt.iskindle and ('MOBI' in self.opt.format or 'EPUB' in self.opt.format):
targetPath += '.webp'
image.save(targetPath, 'WEBP', lossless=True, quality=self.opt.jpegquality)
elif self.opt.kindle_azw3:
targetPath += '.gif' targetPath += '.gif'
image.save(targetPath, 'GIF', optimize=1, interlace=False) image.save(targetPath, 'GIF', optimize=1, interlace=False)
else: else:
targetPath += '.png' targetPath += '.png'
image.save(targetPath, 'PNG', optimize=1) image.save(targetPath, 'PNG', optimize=1)
else: else:
if self.opt.webp_output: targetPath += '.jpg'
targetPath += '.webp' if self.opt.mozjpeg:
image.save(targetPath, 'WEBP', quality=self.opt.jpegquality)
elif self.opt.mozjpeg:
targetPath += '.jpg'
with io.BytesIO() as output: with io.BytesIO() as output:
image.save(output, format="JPEG", optimize=1, quality=self.opt.jpegquality) image.save(output, format="JPEG", optimize=1, quality=85)
input_jpeg_bytes = output.getvalue() input_jpeg_bytes = output.getvalue()
output_jpeg_bytes = mozjpeg_lossless_optimization.optimize(input_jpeg_bytes) output_jpeg_bytes = mozjpeg_lossless_optimization.optimize(input_jpeg_bytes)
with open(targetPath, "wb") as output_jpeg_file: with open(targetPath, "wb") as output_jpeg_file:
output_jpeg_file.write(output_jpeg_bytes) output_jpeg_file.write(output_jpeg_bytes)
else: else:
targetPath += '.jpg' image.save(targetPath, 'JPEG', optimize=1, quality=85)
image.save(targetPath, 'JPEG', optimize=1, quality=self.opt.jpegquality)
return targetPath return targetPath
def gammaCorrectImage(self): def gammaCorrectImage(self):
@@ -500,14 +481,6 @@ class ComicPage:
self.image = erase_rainbow_artifacts(self.image, is_color) self.image = erase_rainbow_artifacts(self.image, is_color)
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:
# TODO: Kindle Scribe case
if self.opt.kindle_azw3 and any(dim > 1920 for dim in self.image.size):
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()
@@ -516,9 +489,7 @@ 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)
elif 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 (self.opt.format in ('CBZ', 'PDF') or self.opt.kfx) and not self.opt.white_borders: 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)
@@ -526,7 +497,7 @@ class ComicPage:
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
@@ -570,7 +541,6 @@ class Cover:
self.options = opt self.options = opt
self.source = source self.source = source
self.image = Image.open(source) self.image = Image.open(source)
self.smartcover = False
# 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
@@ -581,38 +551,30 @@ class Cover:
self.image = ImageOps.autocontrast(self.image, preserve_tone=True) self.image = ImageOps.autocontrast(self.image, preserve_tone=True)
if not self.options.forcecolor: if not self.options.forcecolor:
self.image = self.image.convert('L') self.image = self.image.convert('L')
if not self.options.nosmartcovercrop: self.crop_main_cover()
self.crop_main_cover()
size = list(self.options.profileData[1]) size = list(self.options.profileData[1])
if self.options.kindle_scribe_azw3: if self.options.kindle_scribe_azw3:
size[0] = min(size[0], 1920)
size[1] = min(size[1], 1920) size[1] = min(size[1], 1920)
if self.options.coverfill and not self.options.kindle_scribe_azw3: self.image.thumbnail(tuple(size), Image.Resampling.LANCZOS)
# 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): def crop_main_cover(self):
w, h = self.image.size w, h = self.image.size
if w / h > 2: if w / h > 2:
self.smartcover = True
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.34:
self.smartcover = True
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))
def save_to_folder(self, target, tomeid, len_tomes=0): def save_to_epub(self, target, tomeid, len_tomes=0):
try: try:
if tomeid == 0: if tomeid == 0:
self.image.save(target, "JPEG", optimize=1, quality=self.options.jpegquality) self.image.save(target, "JPEG", optimize=1, quality=85)
else: else:
copy = self.image.copy() copy = self.image.copy()
draw = ImageDraw.Draw(copy) draw = ImageDraw.Draw(copy)
@@ -626,7 +588,7 @@ class Cover:
stroke_fill=0, stroke_fill=0,
stroke_width=25 stroke_width=25
) )
copy.save(target, "JPEG", optimize=1, quality=self.options.jpegquality) copy.save(target, "JPEG", optimize=1, quality=85)
except IOError: except IOError:
raise RuntimeError('Failed to save cover.') raise RuntimeError('Failed to save cover.')
@@ -634,6 +596,6 @@ class Cover:
self.image = ImageOps.contain(self.image, (300, 470), Image.Resampling.LANCZOS) self.image = ImageOps.contain(self.image, (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

@@ -160,8 +160,6 @@ def ignore_pixels_near_edge(bw_img):
for box in edge_bbox: for box in edge_bbox:
edge = bw_img.crop(box) edge = bw_img.crop(box)
h = edge.histogram() h = edge.histogram()
if not edge.height or not edge.width:
continue
imperfections = h[255] / (edge.height * edge.width) imperfections = h[255] / (edge.height * edge.width)
if imperfections > 0 and imperfections < .02: if imperfections > 0 and imperfections < .02:
bw_img.paste(im=0, box=box) bw_img.paste(im=0, box=box)

View File

@@ -1,75 +0,0 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2012-2014 Ciro Mattia Gonano <ciromattia@gmail.com>
# Copyright (c) 2013-2019 Pawel Jastrzebski <pawelj@iosphe.re>
#
# Based upon the code snippet by Ned Batchelder
# (http://nedbatchelder.com/blog/200712/extracting_jpgs_from_pdfs.html)
#
# Permission to use, copy, modify, and/or distribute this software for
# any purpose with or without fee is hereby granted, provided that the
# above copyright notice and this permission notice appear in all
# copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL
# WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE
# AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL
# DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA
# OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
# PERFORMANCE OF THIS SOFTWARE.
#
import os
# skip stray images a few pixels in size in some PDFs
# typical images are many thousands in length
# https://github.com/ciromattia/kcc/pull/546
STRAY_IMAGE_LENGTH_THRESHOLD = 300
class PdfJpgExtract:
def __init__(self, fname, fullPath):
self.fname = fname
self.path = fullPath
def getPath(self):
return self.path
def extract(self):
pdf = open(self.fname, "rb").read()
startmark = b"\xff\xd8"
startfix = 0
endmark = b"\xff\xd9"
endfix = 2
i = 0
njpg = 0
while True:
istream = pdf.find(b"stream", i)
if istream < 0:
break
istart = pdf.find(startmark, istream, istream + 20)
if istart < 0:
i = istream + 20
continue
iend = pdf.find(b"endstream", istart)
if iend < 0:
raise Exception("Didn't find end of stream!")
iend = pdf.find(endmark, iend - 20)
if iend < 0:
raise Exception("Didn't find end of JPG!")
istart += startfix
iend += endfix
i = iend
if iend - istart < STRAY_IMAGE_LENGTH_THRESHOLD:
continue
jpg = pdf[istart:iend]
jpgfile = open(os.path.join(self.path, "jpg%d.jpg" % njpg), "wb")
jpgfile.write(jpg)
jpgfile.close()
njpg += 1
return njpg

View File

@@ -27,9 +27,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)

View File

@@ -6,6 +6,5 @@ packaging>=23.2
mozjpeg-lossless-optimization>=1.2.0 mozjpeg-lossless-optimization>=1.2.0
natsort>=8.4.0 natsort>=8.4.0
distro>=1.8.0 distro>=1.8.0
# Below requirements are compiled in Dockefile numpy>=1.22.4
# numpy==2.3.4 PyMuPDF>=1.18.0
# PyMuPDF==1.26.6

View File

@@ -1,4 +1,4 @@
PySide6==6.4.3 PySide6==6.5.2
Pillow>=11.3.0 Pillow>=11.3.0
psutil>=5.9.5 psutil>=5.9.5
requests>=2.31.0 requests>=2.31.0

View File

@@ -2,7 +2,7 @@ PySide6<6.10
Pillow>=11.3.0 Pillow>=11.3.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.2.0

View File

@@ -40,8 +40,8 @@ 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', '') min_os = os.getenv('MACOSX_DEPLOYMENT_TARGET')
if min_os.startswith('10.1'): if min_os:
os.system(f'appdmg kcc.json dist/kcc_osx_{min_os.replace(".", "_")}_legacy_{VERSION}.dmg') os.system(f'appdmg kcc.json dist/kcc_osx_{min_os.replace(".", "_")}_legacy_{VERSION}.dmg')
else: else:
os.system(f'appdmg kcc.json dist/kcc_macos_{platform.processor()}_{VERSION}.dmg') os.system(f'appdmg kcc.json dist/kcc_macos_{platform.processor()}_{VERSION}.dmg')
@@ -148,15 +148,16 @@ setuptools.setup(
}, },
packages=['kindlecomicconverter'], packages=['kindlecomicconverter'],
install_requires=[ install_requires=[
'PySide6>=6.0.0', 'pyside6>=6.0.0',
'Pillow>=9.3.0', 'Pillow>=9.3.0',
'PyMuPDF>=1.18.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',
'PyMuPDF>=1.16.1', 'PyMuPDF>=1.16.1',
], ],