From e7866e5e3c125b734332dd93843ef683332c0a43 Mon Sep 17 00:00:00 2001 From: Johannes 'josch' Schauer Date: Sun, 5 Apr 2020 20:28:44 +0200 Subject: New upstream version 0.3.4 --- CHANGES.rst | 8 + MANIFEST.in | 1 + PKG-INFO | 78 ++- README.md | 70 +++ magick.py | 306 ++++++++++++ setup.py | 13 +- src/img2pdf.egg-info/PKG-INFO | 78 ++- src/img2pdf.egg-info/SOURCES.txt | 1 + src/img2pdf.egg-info/entry_points.txt | 11 +- src/img2pdf.egg-info/requires.txt | 3 + src/img2pdf.py | 881 +++++++++++++++++++++++++++++++++- test.sh | 58 +-- 12 files changed, 1431 insertions(+), 77 deletions(-) create mode 100644 magick.py diff --git a/CHANGES.rst b/CHANGES.rst index a9ab56b..e7109c5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ CHANGES ======= +0.3.4 (2020-04-05) +------------------ + + - test.sh: replace imagemagick with custom python script to produce bit-by-bit + identical results on all architectures + - add --crop-border, --bleed-border, --trim-border and --art-border options + - first draft of a rudimentary tkinter gui (run with --gui) + 0.3.3 (2019-01-07) ------------------ diff --git a/MANIFEST.in b/MANIFEST.in index d86af25..a217cc3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,7 @@ include README.md include test_comp.sh include test.sh +include magick.py include CHANGES.rst include LICENSE recursive-include src *.jpg diff --git a/PKG-INFO b/PKG-INFO index 7553591..9919a51 100644 --- a/PKG-INFO +++ b/PKG-INFO @@ -1,13 +1,16 @@ Metadata-Version: 2.1 Name: img2pdf -Version: 0.3.3 +Version: 0.3.4 Summary: Convert images to PDF via direct JPEG inclusion. Home-page: https://gitlab.mister-muffin.de/josch/img2pdf Author: Johannes 'josch' Schauer Author-email: josch@mister-muffin.de License: LGPL -Download-URL: https://gitlab.mister-muffin.de/josch/img2pdf/repository/archive.tar.gz?ref=0.3.3 -Description: img2pdf +Download-URL: https://gitlab.mister-muffin.de/josch/img2pdf/repository/archive.tar.gz?ref=0.3.4 +Description: [![Travis Status](https://travis-ci.org/josch/img2pdf.svg?branch=master)](https://travis-ci.org/josch/img2pdf) + [![Appveyor Status](https://ci.appveyor.com/api/projects/status/2kws3wkqvi526llj/branch/master?svg=true)](https://ci.appveyor.com/project/josch/img2pdf/branch/master) + + img2pdf ======= Lossless conversion of raster images to PDF. You should use img2pdf if your @@ -123,6 +126,24 @@ Description: img2pdf $ ve/bin/img2pdf -o test.pdf src/tests/test.jpg + For Microsoft Windows users, PyInstaller based .exe files are produced by + appveyor. If you don't want to install Python before using img2pdf you can head + to appveyor and click on "Artifacts" to download the latest version: + https://ci.appveyor.com/project/josch/img2pdf + + GUI + --- + + There exists an experimental GUI with all settings currently disabled. You can + directly convert images to PDF but you cannot set any options via the GUI yet. + If you are interested in adding more features to the PDF, please submit a merge + request. The GUI is based on tkinter and works on Linux, Windows and MacOS. + + ![](screenshot.png) + + Library + ------- + The package can also be used as a library: import img2pdf @@ -147,6 +168,36 @@ Description: img2pdf with open("name.pdf","wb") as f: f.write(img2pdf.convert(["test1.jpg", "test2.png"])) + # convert all files ending in .jpg inside a directory + dirname = "/path/to/images" + with open("name.pdf","wb") as f: + imgs = [] + for fname in os.listdir(dirname): + if not fname.endswith(".jpg"): + continue + path = os.path.join(dirname, fname) + if os.path.isdir(path): + continue + imgs.append(path) + f.write(img2pdf.convert(imgs)) + + # convert all files ending in .jpg in a directory and its subdirectories + dirname = "/path/to/images" + with open("name.pdf","wb") as f: + imgs = [] + for r, _, f in os.walk(dirname): + for fname in f: + if not fname.endswith(".jpg"): + continue + imgs.append(os.path.join(r, fname)) + f.write(img2pdf.convert(imgs)) + + + # convert all files matching a glob + import glob + with open("name.pdf","wb") as f: + f.write(img2pdf.convert(glob.glob("/path/to/*.jpg"))) + # writing to file descriptor with open("name.pdf","wb") as f1, open("test.jpg") as f2: img2pdf.convert(f2, outputstream=f1) @@ -215,6 +266,24 @@ Description: img2pdf 4500182 original.png 9318120 pdflatex.pdf + Comparison to podofoimg2pdf + --------------------------- + + Like pdfLaTeX, podofoimg2pdf is able to perform a lossless conversion from JPEG + to PDF by plainly embedding the JPEG data into the pdf container. But just like + pdfLaTeX it uses flate compression for all other file formats, thus sometimes + resulting in larger files than necessary. + + $ convert logo: -resize 8000x original.png + $ podofoimg2pdf out.pdf original.png + stat --format="%s %n" original.png out.pdf + 4500181 original.png + 9335629 out.pdf + + It also only supports JPEG, PNG and TIF as input and lacks many of the + convenience features of img2pdf like page sizes, borders, rotation and + metadata. + Comparison to Tesseract OCR --------------------------- @@ -226,6 +295,7 @@ Description: img2pdf input to RGB and removes the alpha channel from images with transparency. For multipage TIFF or animated GIF, it will only convert the first frame. + Keywords: jpeg pdf converter Platform: UNKNOWN Classifier: Development Status :: 5 - Production/Stable @@ -242,4 +312,6 @@ Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3) Classifier: Natural Language :: English Classifier: Operating System :: OS Independent +Description-Content-Type: text/markdown +Provides-Extra: gui Provides-Extra: test diff --git a/README.md b/README.md index ef25643..95e6ee8 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +[![Travis Status](https://travis-ci.org/josch/img2pdf.svg?branch=master)](https://travis-ci.org/josch/img2pdf) +[![Appveyor Status](https://ci.appveyor.com/api/projects/status/2kws3wkqvi526llj/branch/master?svg=true)](https://ci.appveyor.com/project/josch/img2pdf/branch/master) + img2pdf ======= @@ -114,6 +117,24 @@ You can then test the converter using: $ ve/bin/img2pdf -o test.pdf src/tests/test.jpg +For Microsoft Windows users, PyInstaller based .exe files are produced by +appveyor. If you don't want to install Python before using img2pdf you can head +to appveyor and click on "Artifacts" to download the latest version: +https://ci.appveyor.com/project/josch/img2pdf + +GUI +--- + +There exists an experimental GUI with all settings currently disabled. You can +directly convert images to PDF but you cannot set any options via the GUI yet. +If you are interested in adding more features to the PDF, please submit a merge +request. The GUI is based on tkinter and works on Linux, Windows and MacOS. + +![](screenshot.png) + +Library +------- + The package can also be used as a library: import img2pdf @@ -138,6 +159,36 @@ The package can also be used as a library: with open("name.pdf","wb") as f: f.write(img2pdf.convert(["test1.jpg", "test2.png"])) + # convert all files ending in .jpg inside a directory + dirname = "/path/to/images" + with open("name.pdf","wb") as f: + imgs = [] + for fname in os.listdir(dirname): + if not fname.endswith(".jpg"): + continue + path = os.path.join(dirname, fname) + if os.path.isdir(path): + continue + imgs.append(path) + f.write(img2pdf.convert(imgs)) + + # convert all files ending in .jpg in a directory and its subdirectories + dirname = "/path/to/images" + with open("name.pdf","wb") as f: + imgs = [] + for r, _, f in os.walk(dirname): + for fname in f: + if not fname.endswith(".jpg"): + continue + imgs.append(os.path.join(r, fname)) + f.write(img2pdf.convert(imgs)) + + + # convert all files matching a glob + import glob + with open("name.pdf","wb") as f: + f.write(img2pdf.convert(glob.glob("/path/to/*.jpg"))) + # writing to file descriptor with open("name.pdf","wb") as f1, open("test.jpg") as f2: img2pdf.convert(f2, outputstream=f1) @@ -206,6 +257,24 @@ of the plain pixel data and thus needlessly increases the output file size: 4500182 original.png 9318120 pdflatex.pdf +Comparison to podofoimg2pdf +--------------------------- + +Like pdfLaTeX, podofoimg2pdf is able to perform a lossless conversion from JPEG +to PDF by plainly embedding the JPEG data into the pdf container. But just like +pdfLaTeX it uses flate compression for all other file formats, thus sometimes +resulting in larger files than necessary. + + $ convert logo: -resize 8000x original.png + $ podofoimg2pdf out.pdf original.png + stat --format="%s %n" original.png out.pdf + 4500181 original.png + 9335629 out.pdf + +It also only supports JPEG, PNG and TIF as input and lacks many of the +convenience features of img2pdf like page sizes, borders, rotation and +metadata. + Comparison to Tesseract OCR --------------------------- @@ -216,3 +285,4 @@ you should safely be able to use Tesseract instead of img2pdf. For other input, Tesseract might not do a lossless conversion. For example it converts CMYK input to RGB and removes the alpha channel from images with transparency. For multipage TIFF or animated GIF, it will only convert the first frame. + diff --git a/magick.py b/magick.py new file mode 100644 index 0000000..b3dcc18 --- /dev/null +++ b/magick.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 + +import sys +import numpy +import scipy.signal +import zlib +import struct + + +def find_closest_palette_color(color, palette): + if color.ndim == 0: + idx = (numpy.abs(palette - color)).argmin() + else: + # naive distance function by computing the euclidean distance in RGB space + idx = ((palette - color) ** 2).sum(axis=-1).argmin() + return palette[idx] + + +def floyd_steinberg(img, palette): + for y in range(img.shape[0]): + for x in range(img.shape[1]): + oldpixel = img[y, x] + newpixel = find_closest_palette_color(oldpixel, palette) + quant_error = oldpixel - newpixel + img[y, x] = newpixel + if x + 1 < img.shape[1]: + img[y, x + 1] += quant_error * 7 / 16 + if y + 1 < img.shape[0]: + img[y + 1, x - 1] += quant_error * 3 / 16 + img[y + 1, x] += quant_error * 5 / 16 + if x + 1 < img.shape[1] and y + 1 < img.shape[0]: + img[y + 1, x + 1] += quant_error * 1 / 16 + return img + + +def convolve_rgba(img, kernel): + return numpy.stack( + ( + scipy.signal.convolve2d(img[:, :, 0], kernel, "same"), + scipy.signal.convolve2d(img[:, :, 1], kernel, "same"), + scipy.signal.convolve2d(img[:, :, 2], kernel, "same"), + scipy.signal.convolve2d(img[:, :, 3], kernel, "same"), + ), + axis=-1, + ) + + +def rgb2gray(img): + result = numpy.zeros((60, 60), dtype=numpy.dtype("int64")) + for y in range(img.shape[0]): + for x in range(img.shape[1]): + clin = sum(img[y, x] * [0.2126, 0.7152, 0.0722]) / 0xFFFF + if clin <= 0.0031308: + csrgb = 12.92 * clin + else: + csrgb = 1.055 * clin ** (1 / 2.4) - 0.055 + result[y, x] = csrgb * 0xFFFF + return result + + +def palettize(img, pal): + result = numpy.zeros((img.shape[0], img.shape[1]), dtype=numpy.dtype("int64")) + for y in range(img.shape[0]): + for x in range(img.shape[1]): + for i, col in enumerate(pal): + if numpy.array_equal(img[y, x], col): + result[y, x] = i + break + else: + raise Exception() + return result + + +def write_png(data, path, bitdepth, colortype, palette=None): + with open(path, "wb") as f: + f.write(b"\x89PNG\r\n\x1A\n") + # PNG image type Colour type Allowed bit depths + # Greyscale 0 1, 2, 4, 8, 16 + # Truecolour 2 8, 16 + # Indexed-colour 3 1, 2, 4, 8 + # Greyscale with alpha 4 8, 16 + # Truecolour with alpha 6 8, 16 + block = b"IHDR" + struct.pack( + ">IIBBBBB", + data.shape[1], # width + data.shape[0], # height + bitdepth, # bitdepth + colortype, # colortype + 0, # compression + 0, # filtertype + 0, # interlaced + ) + f.write( + struct.pack(">I", len(block) - 4) + + block + + struct.pack(">I", zlib.crc32(block)) + ) + if palette is not None: + block = b"PLTE" + for col in palette: + block += struct.pack(">BBB", col[0], col[1], col[2]) + f.write( + struct.pack(">I", len(block) - 4) + + block + + struct.pack(">I", zlib.crc32(block)) + ) + raw = b"" + for y in range(data.shape[0]): + raw += b"\0" + if bitdepth == 16: + raw += data[y].astype(">u2").tobytes() + elif bitdepth == 8: + raw += data[y].astype(">u1").tobytes() + elif bitdepth in [4, 2, 1]: + valsperbyte = 8 // bitdepth + for x in range(0, data.shape[1], valsperbyte): + val = 0 + for j in range(valsperbyte): + if x + j >= data.shape[1]: + break + val |= (data[y, x + j].astype(">u2") & (2 ** bitdepth - 1)) << ( + (valsperbyte - j - 1) * bitdepth + ) + raw += struct.pack(">B", val) + else: + raise Exception() + compressed = zlib.compress(raw) + block = b"IDAT" + compressed + f.write( + struct.pack(">I", len(compressed)) + + block + + struct.pack(">I", zlib.crc32(block)) + ) + block = b"IEND" + f.write(struct.pack(">I", 0) + block + struct.pack(">I", zlib.crc32(block))) + + +def main(): + outdir = sys.argv[1] + + # create a 256 color palette by first writing 16 shades of gray + # and then writing an array of RGB colors with 6, 8 and 5 levels + # for red, green and blue, respectively + pal8 = numpy.zeros((256, 3), dtype=numpy.dtype("int64")) + i = 0 + for gray in range(15, 255, 15): + pal8[i] = [gray, gray, gray] + i += 1 + for red in 0, 0x33, 0x66, 0x99, 0xCC, 0xFF: + for green in 0, 0x24, 0x49, 0x6D, 0x92, 0xB6, 0xDB, 0xFF: + for blue in 0, 0x40, 0x80, 0xBF, 0xFF: + pal8[i] = [red, green, blue] + i += 1 + assert i == 256 + + # windows 16 color palette + pal4 = numpy.array( + [ + [0x00, 0x00, 0x00], + [0x80, 0x00, 0x00], + [0x00, 0x80, 0x00], + [0x80, 0x80, 0x00], + [0x00, 0x00, 0x80], + [0x80, 0x00, 0x80], + [0x00, 0x80, 0x80], + [0xC0, 0xC0, 0xC0], + [0x80, 0x80, 0x80], + [0xFF, 0x00, 0x00], + [0x00, 0xFF, 0x00], + [0xFF, 0x00, 0x00], + [0x00, 0xFF, 0x00], + [0xFF, 0x00, 0xFF], + [0x00, 0xFF, 0x00], + [0xFF, 0xFF, 0xFF], + ], + dtype=numpy.dtype("int64"), + ) + + # choose values slightly off red, lime and blue because otherwise + # imagemagick will classify the image as Depth: 8/1-bit + pal2 = numpy.array( + [[0, 0, 0], [0xFE, 0, 0], [0, 0xFE, 0], [0, 0, 0xFE]], + dtype=numpy.dtype("int64"), + ) + + # don't choose black and white or otherwise imagemagick will classify the + # image as bilevel with 8/1-bit depth instead of palette with 8-bit color + # don't choose gray colors or otherwise imagemagick will classify the + # image as grayscale + pal1 = numpy.array( + [[0x01, 0x02, 0x03], [0xFE, 0xFD, 0xFC]], dtype=numpy.dtype("int64") + ) + + # gaussian kernel with sigma=3 + kernel = numpy.array( + [ + [0.011362, 0.014962, 0.017649, 0.018648, 0.017649, 0.014962, 0.011362], + [0.014962, 0.019703, 0.02324, 0.024556, 0.02324, 0.019703, 0.014962], + [0.017649, 0.02324, 0.027413, 0.028964, 0.027413, 0.02324, 0.017649], + [0.018648, 0.024556, 0.028964, 0.030603, 0.028964, 0.024556, 0.018648], + [0.017649, 0.02324, 0.027413, 0.028964, 0.027413, 0.02324, 0.017649], + [0.014962, 0.019703, 0.02324, 0.024556, 0.02324, 0.019703, 0.014962], + [0.011362, 0.014962, 0.017649, 0.018648, 0.017649, 0.014962, 0.011362], + ], + numpy.float, + ) + + # constructs a 2D array of a circle with a width of 36 + circle = list() + offsets_36 = [14, 11, 9, 7, 6, 5, 4, 3, 3, 2, 2, 1, 1, 1, 0, 0, 0, 0] + for offs in offsets_36 + offsets_36[::-1]: + circle.append([0] * offs + [1] * (len(offsets_36) - offs) * 2 + [0] * offs) + + alpha = numpy.zeros((60, 60, 4), dtype=numpy.dtype("int64")) + + # draw three circles + for (xpos, ypos, color) in [ + (12, 3, [0xFFFF, 0, 0, 0xFFFF]), + (21, 21, [0, 0xFFFF, 0, 0xFFFF]), + (3, 21, [0, 0, 0xFFFF, 0xFFFF]), + ]: + for x, row in enumerate(circle): + for y, pos in enumerate(row): + if pos: + alpha[y + ypos, x + xpos] += color + alpha = numpy.clip(alpha, 0, 0xFFFF) + alpha = convolve_rgba(alpha, kernel) + + write_png(alpha, outdir + "/alpha.png", 16, 6) + + normal16 = alpha[:, :, 0:3] + write_png(normal16, outdir + "/normal16.png", 16, 2) + + write_png(normal16 / 0xFFFF * 0xFF, outdir + "/normal.png", 8, 2) + + write_png(0xFF - normal16 / 0xFFFF * 0xFF, outdir + "/inverse.png", 8, 2) + + gray16 = rgb2gray(normal16) + + write_png(gray16, outdir + "/gray16.png", 16, 0) + + write_png(gray16 / 0xFFFF * 0xFF, outdir + "/gray8.png", 8, 0) + + write_png( + floyd_steinberg(gray16, numpy.arange(16) / 0xF * 0xFFFF) / 0xFFFF * 0xF, + outdir + "/gray4.png", + 4, + 0, + ) + + write_png( + floyd_steinberg(gray16, numpy.arange(4) / 0x3 * 0xFFFF) / 0xFFFF * 0x3, + outdir + "/gray2.png", + 2, + 0, + ) + + write_png( + floyd_steinberg(gray16, numpy.arange(2) / 0x1 * 0xFFFF) / 0xFFFF * 0x1, + outdir + "/gray1.png", + 1, + 0, + ) + + write_png( + palettize( + floyd_steinberg(normal16, pal8 * 0xFFFF / 0xFF) / 0xFFFF * 0xFF, pal8 + ), + outdir + "/palette8.png", + 8, + 3, + pal8, + ) + + write_png( + palettize( + floyd_steinberg(normal16, pal4 * 0xFFFF / 0xFF) / 0xFFFF * 0xFF, pal4 + ), + outdir + "/palette4.png", + 4, + 3, + pal4, + ) + + write_png( + palettize( + floyd_steinberg(normal16, pal2 * 0xFFFF / 0xFF) / 0xFFFF * 0xFF, pal2 + ), + outdir + "/palette2.png", + 2, + 3, + pal2, + ) + + write_png( + palettize( + floyd_steinberg(normal16, pal1 * 0xFFFF / 0xFF) / 0xFFFF * 0xFF, pal1 + ), + outdir + "/palette1.png", + 1, + 3, + pal1, + ) + + +main() diff --git a/setup.py b/setup.py index b0438fe..c467cd7 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import setup PY3 = sys.version_info[0] >= 3 -VERSION = "0.3.3" +VERSION = "0.3.4" INSTALL_REQUIRES = ( 'Pillow', @@ -24,6 +24,7 @@ setup( author_email='josch@mister-muffin.de', description="Convert images to PDF via direct JPEG inclusion.", long_description=open('README.md').read(), + long_description_content_type='text/markdown', license="LGPL", keywords="jpeg pdf converter", classifiers=[ @@ -54,9 +55,11 @@ setup( tests_requires=TESTS_REQUIRE, extras_require={ 'test': TESTS_REQUIRE, + 'gui': ('tkinter'), + }, + entry_points={ + "setuptools.installation": ["eggsecutable = img2pdf:main"], + "console_scripts": ["img2pdf = img2pdf:main"], + "gui_scripts": ["img2pdf-gui = img2pdf:gui"], }, - entry_points=''' - [console_scripts] - img2pdf = img2pdf:main - ''', ) diff --git a/src/img2pdf.egg-info/PKG-INFO b/src/img2pdf.egg-info/PKG-INFO index 7553591..9919a51 100644 --- a/src/img2pdf.egg-info/PKG-INFO +++ b/src/img2pdf.egg-info/PKG-INFO @@ -1,13 +1,16 @@ Metadata-Version: 2.1 Name: img2pdf -Version: 0.3.3 +Version: 0.3.4 Summary: Convert images to PDF via direct JPEG inclusion. Home-page: https://gitlab.mister-muffin.de/josch/img2pdf Author: Johannes 'josch' Schauer Author-email: josch@mister-muffin.de License: LGPL -Download-URL: https://gitlab.mister-muffin.de/josch/img2pdf/repository/archive.tar.gz?ref=0.3.3 -Description: img2pdf +Download-URL: https://gitlab.mister-muffin.de/josch/img2pdf/repository/archive.tar.gz?ref=0.3.4 +Description: [![Travis Status](https://travis-ci.org/josch/img2pdf.svg?branch=master)](https://travis-ci.org/josch/img2pdf) + [![Appveyor Status](https://ci.appveyor.com/api/projects/status/2kws3wkqvi526llj/branch/master?svg=true)](https://ci.appveyor.com/project/josch/img2pdf/branch/master) + + img2pdf ======= Lossless conversion of raster images to PDF. You should use img2pdf if your @@ -123,6 +126,24 @@ Description: img2pdf $ ve/bin/img2pdf -o test.pdf src/tests/test.jpg + For Microsoft Windows users, PyInstaller based .exe files are produced by + appveyor. If you don't want to install Python before using img2pdf you can head + to appveyor and click on "Artifacts" to download the latest version: + https://ci.appveyor.com/project/josch/img2pdf + + GUI + --- + + There exists an experimental GUI with all settings currently disabled. You can + directly convert images to PDF but you cannot set any options via the GUI yet. + If you are interested in adding more features to the PDF, please submit a merge + request. The GUI is based on tkinter and works on Linux, Windows and MacOS. + + ![](screenshot.png) + + Library + ------- + The package can also be used as a library: import img2pdf @@ -147,6 +168,36 @@ Description: img2pdf with open("name.pdf","wb") as f: f.write(img2pdf.convert(["test1.jpg", "test2.png"])) + # convert all files ending in .jpg inside a directory + dirname = "/path/to/images" + with open("name.pdf","wb") as f: + imgs = [] + for fname in os.listdir(dirname): + if not fname.endswith(".jpg"): + continue + path = os.path.join(dirname, fname) + if os.path.isdir(path): + continue + imgs.append(path) + f.write(img2pdf.convert(imgs)) + + # convert all files ending in .jpg in a directory and its subdirectories + dirname = "/path/to/images" + with open("name.pdf","wb") as f: + imgs = [] + for r, _, f in os.walk(dirname): + for fname in f: + if not fname.endswith(".jpg"): + continue + imgs.append(os.path.join(r, fname)) + f.write(img2pdf.convert(imgs)) + + + # convert all files matching a glob + import glob + with open("name.pdf","wb") as f: + f.write(img2pdf.convert(glob.glob("/path/to/*.jpg"))) + # writing to file descriptor with open("name.pdf","wb") as f1, open("test.jpg") as f2: img2pdf.convert(f2, outputstream=f1) @@ -215,6 +266,24 @@ Description: img2pdf 4500182 original.png 9318120 pdflatex.pdf + Comparison to podofoimg2pdf + --------------------------- + + Like pdfLaTeX, podofoimg2pdf is able to perform a lossless conversion from JPEG + to PDF by plainly embedding the JPEG data into the pdf container. But just like + pdfLaTeX it uses flate compression for all other file formats, thus sometimes + resulting in larger files than necessary. + + $ convert logo: -resize 8000x original.png + $ podofoimg2pdf out.pdf original.png + stat --format="%s %n" original.png out.pdf + 4500181 original.png + 9335629 out.pdf + + It also only supports JPEG, PNG and TIF as input and lacks many of the + convenience features of img2pdf like page sizes, borders, rotation and + metadata. + Comparison to Tesseract OCR --------------------------- @@ -226,6 +295,7 @@ Description: img2pdf input to RGB and removes the alpha channel from images with transparency. For multipage TIFF or animated GIF, it will only convert the first frame. + Keywords: jpeg pdf converter Platform: UNKNOWN Classifier: Development Status :: 5 - Production/Stable @@ -242,4 +312,6 @@ Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3) Classifier: Natural Language :: English Classifier: Operating System :: OS Independent +Description-Content-Type: text/markdown +Provides-Extra: gui Provides-Extra: test diff --git a/src/img2pdf.egg-info/SOURCES.txt b/src/img2pdf.egg-info/SOURCES.txt index 6fa068a..fab061c 100644 --- a/src/img2pdf.egg-info/SOURCES.txt +++ b/src/img2pdf.egg-info/SOURCES.txt @@ -2,6 +2,7 @@ CHANGES.rst LICENSE MANIFEST.in README.md +magick.py setup.cfg setup.py test.sh diff --git a/src/img2pdf.egg-info/entry_points.txt b/src/img2pdf.egg-info/entry_points.txt index 59301dc..25efe55 100644 --- a/src/img2pdf.egg-info/entry_points.txt +++ b/src/img2pdf.egg-info/entry_points.txt @@ -1,4 +1,9 @@ +[console_scripts] +img2pdf = img2pdf:main + +[gui_scripts] +img2pdf-gui = img2pdf:gui + +[setuptools.installation] +eggsecutable = img2pdf:main - [console_scripts] - img2pdf = img2pdf:main - \ No newline at end of file diff --git a/src/img2pdf.egg-info/requires.txt b/src/img2pdf.egg-info/requires.txt index 3a24589..e1f01c5 100644 --- a/src/img2pdf.egg-info/requires.txt +++ b/src/img2pdf.egg-info/requires.txt @@ -1,4 +1,7 @@ Pillow +[gui] +tkinter + [test] pdfrw diff --git a/src/img2pdf.py b/src/img2pdf.py index 27e5b8c..764e745 100755 --- a/src/img2pdf.py +++ b/src/img2pdf.py @@ -32,10 +32,11 @@ from enum import Enum from io import BytesIO import logging import struct +import platform PY3 = sys.version_info[0] >= 3 -__version__ = "0.3.3" +__version__ = "0.3.4" default_dpi = 96.0 papersizes = { "letter": "8.5inx11in", @@ -363,6 +364,14 @@ class PdfTooLargeError(Exception): pass +class AlphaChannelError(Exception): + pass + + +class ExifOrientationError(Exception): + pass + + # without pdfrw this function is a no-op def my_convert_load(string): return string @@ -447,6 +456,9 @@ class MyPdfDict(object): def __getitem__(self, key): return self.content[key] + def __contains__(self, key): + return key in self.content + class MyPdfName: def __getattr__(self, name): @@ -709,6 +721,10 @@ class pdfdoc(object): inverted=False, depth=0, rotate=0, + cropborder=None, + bleedborder=None, + trimborder=None, + artborder=None, ): if self.with_pdfrw: from pdfrw import PdfDict, PdfName, PdfObject, PdfString @@ -801,6 +817,48 @@ class pdfdoc(object): page = PdfDict(indirect=True) page[PdfName.Type] = PdfName.Page page[PdfName.MediaBox] = [0, 0, pagewidth, pageheight] + # 14.11.2 Page Boundaries + # ... + # The crop, bleed, trim, and art boxes shall not ordinarily extend + # beyond the boundaries of the media box. If they do, they are + # effectively reduced to their intersection with the media box. + if cropborder is not None: + page[PdfName.CropBox] = [ + cropborder[1], + cropborder[0], + pagewidth - 2 * cropborder[1], + pageheight - 2 * cropborder[0], + ] + if bleedborder is None: + if PdfName.CropBox in page: + page[PdfName.BleedBox] = page[PdfName.CropBox] + else: + page[PdfName.BleedBox] = [ + bleedborder[1], + bleedborder[0], + pagewidth - 2 * bleedborder[1], + pageheight - 2 * bleedborder[0], + ] + if trimborder is None: + if PdfName.CropBox in page: + page[PdfName.TrimBox] = page[PdfName.CropBox] + else: + page[PdfName.TrimBox] = [ + trimborder[1], + trimborder[0], + pagewidth - 2 * trimborder[1], + pageheight - 2 * trimborder[0], + ] + if artborder is None: + if PdfName.CropBox in page: + page[PdfName.ArtBox] = page[PdfName.CropBox] + else: + page[PdfName.ArtBox] = [ + artborder[1], + artborder[0], + pagewidth - 2 * artborder[1], + pageheight - 2 * artborder[0], + ] page[PdfName.Resources] = resources page[PdfName.Contents] = content if rotate != 0: @@ -978,16 +1036,14 @@ def get_imgmetadata(imgdata, imgformat, default_dpi, colorspace, rawdata=None): ics = imgdata.mode if ics in ["LA", "PA", "RGBA"] or "transparency" in imgdata.info: - logging.warning( - "Image contains transparency which cannot be retained " "in PDF." - ) + logging.warning("Image contains transparency which cannot be retained in PDF.") logging.warning("img2pdf will not perform a lossy operation.") logging.warning("You can remove the alpha channel using imagemagick:") logging.warning( " $ convert input.png -background white -alpha " "remove -alpha off output.png" ) - raise Exception("Refusing to work on images with alpha channel") + raise AlphaChannelError("Refusing to work on images with alpha channel") # Since commit 07a96209597c5e8dfe785c757d7051ce67a980fb or release 4.1.0 # Pillow retrieves the DPI from EXIF if it cannot find the DPI in the JPEG @@ -1021,14 +1077,11 @@ def get_imgmetadata(imgdata, imgformat, default_dpi, colorspace, rawdata=None): elif value == 8: rotation = 270 elif value in (2, 4, 5, 7): - raise Exception( - 'Image "%s": Unsupported flipped ' - "rotation mode (%d)" % (im.name, value) + raise ExifOrientationError( + "Unsupported flipped rotation mode (%d)" % value ) else: - raise Exception( - 'Image "%s": invalid rotation (%d)' % (im.name, value) - ) + raise ExifOrientationError("Invalid rotation (%d)" % value) logging.debug("rotation = %d°", rotation) @@ -1479,14 +1532,14 @@ def get_layout_fun( and fitheight < 0 ): raise ValueError( - "cannot fit into a rectangle where both " "dimensions are negative" + "cannot fit into a rectangle where both dimensions are negative" ) elif fit not in [FitMode.fill, FitMode.enlarge] and ( (fitwidth is not None and fitwidth < 0) or (fitheight is not None and fitheight < 0) ): raise Exception( - "cannot fit into a rectangle where either " "dimensions are negative" + "cannot fit into a rectangle where either dimensions are negative" ) def default(): @@ -1760,6 +1813,10 @@ def convert(*images, **kwargs): outputstream=None, first_frame_only=False, allow_oversized=True, + cropborder=None, + bleedborder=None, + trimborder=None, + artborder=None, ) for kwname, default in _default_kwargs.items(): if kwname not in kwargs: @@ -1807,13 +1864,19 @@ def convert(*images, **kwargs): # the thing doesn't have a read() function, so try if we can treat # it as a file name try: - with open(img, "rb") as f: - rawdata = f.read() + f = open(img, "rb") except Exception: # whatever the exception is (string could contain NUL # characters or the path could just not exist) it's not a file # name so we now try treating it as raw image content rawdata = img + else: + # we are not using a "with" block here because we only want to + # catch exceptions thrown by open(). The read() may throw its + # own exceptions like MemoryError which should be handled + # differently. + rawdata = f.read() + f.close() for ( color, @@ -1834,7 +1897,7 @@ def convert(*images, **kwargs): userunit = None if pagewidth < 3.00 or pageheight < 3.00: logging.warning( - "pdf width or height is below 3.00 - too " "small for some viewers!" + "pdf width or height is below 3.00 - too small for some viewers!" ) elif pagewidth > 14400.0 or pageheight > 14400.0: if kwargs["allow_oversized"]: @@ -1867,6 +1930,10 @@ def convert(*images, **kwargs): inverted, depth, rotation, + kwargs["cropborder"], + kwargs["bleedborder"], + kwargs["trimborder"], + kwargs["artborder"], ) if kwargs["outputstream"]: @@ -1988,7 +2055,7 @@ def parse_pagesize_rectarg(string): if transposed: w, h = h, w if w is None and h is None: - raise argparse.ArgumentTypeError("at least one dimension must be " "specified") + raise argparse.ArgumentTypeError("at least one dimension must be specified") return w, h @@ -2010,7 +2077,7 @@ def parse_imgsize_rectarg(string): if transposed: w, h = h, w if w is None and h is None: - raise argparse.ArgumentTypeError("at least one dimension must be " "specified") + raise argparse.ArgumentTypeError("at least one dimension must be specified") return w, h @@ -2020,7 +2087,7 @@ def parse_colorspacearg(string): return c allowed = ", ".join([c.name for c in Colorspace]) raise argparse.ArgumentTypeError( - "Unsupported colorspace: %s. Must be one " "of: %s." % (string, allowed) + "Unsupported colorspace: %s. Must be one of: %s." % (string, allowed) ) @@ -2094,7 +2161,7 @@ def parse_panes(string): return m allowed = ", ".join([m.name for m in PageMode]) raise argparse.ArgumentTypeError( - "Unsupported page mode: %s. Must be one " "of: %s." % (string, allowed) + "Unsupported page mode: %s. Must be one of: %s." % (string, allowed) ) @@ -2119,7 +2186,7 @@ def parse_layout(string): return l allowed = ", ".join([l.name for l in PageLayout]) raise argparse.ArgumentTypeError( - "Unsupported page layout: %s. Must be " "one of: %s." % (string, allowed) + "Unsupported page layout: %s. Must be one of: %s." % (string, allowed) ) @@ -2162,6 +2229,703 @@ def valid_date(string): raise argparse.ArgumentTypeError("cannot parse date: %s" % string) +def gui(): + import tkinter + import tkinter.filedialog + + have_fitz = True + try: + import fitz + except ImportError: + have_fitz = False + + # from Python 3.7 Lib/idlelib/configdialog.py + # Copyright 2015-2017 Terry Jan Reedy + # Python License + class VerticalScrolledFrame(tkinter.Frame): + """A pure Tkinter vertically scrollable frame. + + * Use the 'interior' attribute to place widgets inside the scrollable frame + * Construct and pack/place/grid normally + * This frame only allows vertical scrolling + """ + + def __init__(self, parent, *args, **kw): + tkinter.Frame.__init__(self, parent, *args, **kw) + + # Create a canvas object and a vertical scrollbar for scrolling it. + vscrollbar = tkinter.Scrollbar(self, orient=tkinter.VERTICAL) + vscrollbar.pack(fill=tkinter.Y, side=tkinter.RIGHT, expand=tkinter.FALSE) + canvas = tkinter.Canvas( + self, + borderwidth=0, + highlightthickness=0, + yscrollcommand=vscrollbar.set, + width=240, + ) + canvas.pack(side=tkinter.LEFT, fill=tkinter.BOTH, expand=tkinter.TRUE) + vscrollbar.config(command=canvas.yview) + + # Reset the view. + canvas.xview_moveto(0) + canvas.yview_moveto(0) + + # Create a frame inside the canvas which will be scrolled with it. + self.interior = interior = tkinter.Frame(canvas) + interior_id = canvas.create_window(0, 0, window=interior, anchor=tkinter.NW) + + # Track changes to the canvas and frame width and sync them, + # also updating the scrollbar. + def _configure_interior(event): + # Update the scrollbars to match the size of the inner frame. + size = (interior.winfo_reqwidth(), interior.winfo_reqheight()) + canvas.config(scrollregion="0 0 %s %s" % size) + + interior.bind("", _configure_interior) + + def _configure_canvas(event): + if interior.winfo_reqwidth() != canvas.winfo_width(): + # Update the inner frame's width to fill the canvas. + canvas.itemconfigure(interior_id, width=canvas.winfo_width()) + + canvas.bind("", _configure_canvas) + + return + + # From Python 3.7 Lib/tkinter/__init__.py + # Copyright 2000 Fredrik Lundh + # Python License + # + # add support for 'state' and 'name' kwargs + # add support for updating list of options + class OptionMenu(tkinter.Menubutton): + """OptionMenu which allows the user to select a value from a menu.""" + + def __init__(self, master, variable, value, *values, **kwargs): + """Construct an optionmenu widget with the parent MASTER, with + the resource textvariable set to VARIABLE, the initially selected + value VALUE, the other menu values VALUES and an additional + keyword argument command.""" + kw = { + "borderwidth": 2, + "textvariable": variable, + "indicatoron": 1, + "relief": tkinter.RAISED, + "anchor": "c", + "highlightthickness": 2, + } + if "state" in kwargs: + kw["state"] = kwargs["state"] + del kwargs["state"] + if "name" in kwargs: + kw["name"] = kwargs["name"] + del kwargs["name"] + tkinter.Widget.__init__(self, master, "menubutton", kw) + self.widgetName = "tk_optionMenu" + self.callback = kwargs.get("command") + self.variable = variable + if "command" in kwargs: + del kwargs["command"] + if kwargs: + raise tkinter.TclError("unknown option -" + list(kwargs.keys())[0]) + self.set_values([value] + list(values)) + + def __getitem__(self, name): + if name == "menu": + return self.__menu + return tkinter.Widget.__getitem__(self, name) + + def set_values(self, values): + menu = self.__menu = tkinter.Menu(self, name="menu", tearoff=0) + self.menuname = menu._w + for v in values: + menu.add_command( + label=v, command=tkinter._setit(self.variable, v, self.callback) + ) + self["menu"] = menu + + def destroy(self): + """Destroy this widget and the associated menu.""" + tkinter.Menubutton.destroy(self) + self.__menu = None + + root = tkinter.Tk() + app = tkinter.Frame(master=root) + + infiles = [] + maxpagewidth = 0 + maxpageheight = 0 + doc = None + + args = { + "without_pdfrw": tkinter.BooleanVar(), + "first_frame_only": tkinter.BooleanVar(), + "auto_orient": tkinter.BooleanVar(), + "fit": tkinter.StringVar(), + "title": tkinter.StringVar(), + "author": tkinter.StringVar(), + "creator": tkinter.StringVar(), + "producer": tkinter.StringVar(), + "subject": tkinter.StringVar(), + "keywords": tkinter.StringVar(), + "nodate": tkinter.BooleanVar(), + "creationdate": tkinter.StringVar(), + "moddate": tkinter.StringVar(), + "viewer_panes": tkinter.StringVar(), + "viewer_initial_page": tkinter.IntVar(), + "viewer_magnification": tkinter.StringVar(), + "viewer_page_layout": tkinter.StringVar(), + "viewer_fit_window": tkinter.BooleanVar(), + "viewer_center_window": tkinter.BooleanVar(), + "viewer_fullscreen": tkinter.BooleanVar(), + "pagesize_dropdown": tkinter.StringVar(), + "pagesize_width": tkinter.DoubleVar(), + "pagesize_height": tkinter.DoubleVar(), + "imgsize_dropdown": tkinter.StringVar(), + "imgsize_width": tkinter.DoubleVar(), + "imgsize_height": tkinter.DoubleVar(), + "colorspace": tkinter.StringVar(), + "first_frame_only": tkinter.BooleanVar(), + } + args["title"].set("") + args["auto_orient"].set(False) + args["fit"].set("into") + args["colorspace"].set("auto") + args["viewer_panes"].set("auto") + args["viewer_initial_page"].set(1) + args["viewer_magnification"].set("auto") + args["viewer_page_layout"].set("auto") + args["first_frame_only"].set(False) + args["pagesize_dropdown"].set("auto") + args["imgsize_dropdown"].set("auto") + + def on_open_button(): + nonlocal infiles + nonlocal doc + nonlocal maxpagewidth + nonlocal maxpageheight + infiles = tkinter.filedialog.askopenfilenames( + parent=root, + title="open image", + filetypes=[ + ( + "images", + "*.bmp *.eps *.gif *.ico *.jpeg *.jpg *.jp2 *.pcx *.png *.ppm *.tiff", + ), + ("all files", "*"), + ], + # initialdir="/home/josch/git/plakativ", + # initialfile="test.pdf", + ) + if have_fitz: + with BytesIO() as f: + save_pdf(f) + f.seek(0) + doc = fitz.open(stream=f, filetype="pdf") + for page in doc: + if page.getDisplayList().rect.width > maxpagewidth: + maxpagewidth = page.getDisplayList().rect.width + if page.getDisplayList().rect.height > maxpageheight: + maxpageheight = page.getDisplayList().rect.height + draw() + + def save_pdf(stream): + pagesizearg = None + if args["pagesize_dropdown"].get() == "auto": + # nothing to do + pass + elif args["pagesize_dropdown"].get() == "custom": + pagesizearg = args["pagesize_width"].get(), args["pagesize_height"].get() + elif args["pagesize_dropdown"].get() in papernames.values(): + raise NotImplemented() + else: + raise Exception("no such pagesize: %s" % args["pagesize_dropdown"].get()) + imgsizearg = None + if args["imgsize_dropdown"].get() == "auto": + # nothing to do + pass + elif args["imgsize_dropdown"].get() == "custom": + imgsizearg = args["imgsize_width"].get(), args["imgsize_height"].get() + elif args["imgsize_dropdown"].get() in papernames.values(): + raise NotImplemented() + else: + raise Exception("no such imgsize: %s" % args["imgsize_dropdown"].get()) + borderarg = None + layout_fun = get_layout_fun( + pagesizearg, + imgsizearg, + borderarg, + args["fit"].get(), + args["auto_orient"].get(), + ) + viewer_panesarg = None + if args["viewer_panes"].get() == "auto": + # nothing to do + pass + elif args["viewer_panes"].get() in PageMode: + viewer_panesarg = args["viewer_panes"].get() + else: + raise Exception("no such viewer_panes: %s" % args["viewer_panes"].get()) + viewer_magnificationarg = None + if args["viewer_magnification"].get() == "auto": + # nothing to do + pass + elif args["viewer_magnification"].get() in Magnification: + viewer_magnificationarg = args["viewer_magnification"].get() + else: + raise Exception( + "no such viewer_magnification: %s" % args["viewer_magnification"].get() + ) + viewer_page_layoutarg = None + if args["viewer_page_layout"].get() == "auto": + # nothing to do + pass + elif args["viewer_page_layout"].get() in PageLayout: + viewer_page_layoutarg = args["viewer_page_layout"].get() + else: + raise Exception( + "no such viewer_page_layout: %s" % args["viewer_page_layout"].get() + ) + colorspacearg = None + if args["colorspace"].get() != "auto": + colorspacearg = next( + v for v in Colorspace if v.name == args["colorspace"].get() + ) + + convert( + *infiles, + title=args["title"].get() if args["title"].get() else None, + author=args["author"].get() if args["author"].get() else None, + creator=args["creator"].get() if args["creator"].get() else None, + producer=args["producer"].get() if args["producer"].get() else None, + creationdate=args["creationdate"].get() + if args["creationdate"].get() + else None, + moddate=args["moddate"].get() if args["moddate"].get() else None, + subject=args["subject"].get() if args["subject"].get() else None, + keywords=args["keywords"].get() if args["keywords"].get() else None, + colorspace=colorspacearg, + nodate=args["nodate"].get(), + layout_fun=layout_fun, + viewer_panes=viewer_panesarg, + viewer_initial_page=args["viewer_initial_page"].get() + if args["viewer_initial_page"].get() > 1 + else None, + viewer_magnification=viewer_magnificationarg, + viewer_page_layout=viewer_page_layoutarg, + viewer_fit_window=(args["viewer_fit_window"].get() or None), + viewer_center_window=(args["viewer_center_window"].get() or None), + viewer_fullscreen=(args["viewer_fullscreen"].get() or None), + with_pdfrw=not args["without_pdfrw"].get(), + outputstream=stream, + first_frame_only=args["first_frame_only"].get(), + cropborder=None, + bleedborder=None, + trimborder=None, + artborder=None, + ) + + def on_save_button(): + filename = tkinter.filedialog.asksaveasfilename( + parent=root, + title="save PDF", + defaultextension=".pdf", + filetypes=[("pdf documents", "*.pdf"), ("all files", "*")], + # initialdir="/home/josch/git/plakativ", + # initialfile=base + "_poster" + ext, + ) + with open(filename, "wb") as f: + save_pdf(f) + + root.title("img2pdf") + app.pack(fill=tkinter.BOTH, expand=tkinter.TRUE) + + canvas = tkinter.Canvas(app, bg="black") + + def draw(): + canvas.delete(tkinter.ALL) + if not infiles: + canvas.create_text( + canvas.size[0] / 2, + canvas.size[1] / 2, + text='Click on the "Open Image(s)" button in the upper right.', + fill="white", + ) + return + + if not doc: + canvas.create_text( + canvas.size[0] / 2, + canvas.size[1] / 2, + text="PyMuPDF not available. Install the Python fitz module\n" + + "for preview functionality.", + fill="white", + ) + return + + canvas_padding = 10 + # factor to convert from pdf dimensions (given in pt) into canvas + # dimensions (given in pixels) + zoom = min( + (canvas.size[0] - canvas_padding) / maxpagewidth, + (canvas.size[1] - canvas_padding) / maxpageheight, + ) + + pagenum = 0 + mat_0 = fitz.Matrix(zoom, zoom) + canvas.image = tkinter.PhotoImage( + data=doc[pagenum] + .getDisplayList() + .getPixmap(matrix=mat_0, alpha=False) + .getImageData("ppm") + ) + canvas.create_image( + (canvas.size[0] - maxpagewidth * zoom) / 2, + (canvas.size[1] - maxpageheight * zoom) / 2, + anchor=tkinter.NW, + image=canvas.image, + ) + + canvas.create_rectangle( + (canvas.size[0] - maxpagewidth * zoom) / 2, + (canvas.size[1] - maxpageheight * zoom) / 2, + (canvas.size[0] - maxpagewidth * zoom) / 2 + canvas.image.width(), + (canvas.size[1] - maxpageheight * zoom) / 2 + canvas.image.height(), + outline="red", + ) + + def on_resize(event): + canvas.size = (event.width, event.height) + draw() + + canvas.pack(fill=tkinter.BOTH, side=tkinter.LEFT, expand=tkinter.TRUE) + canvas.bind("", on_resize) + + frame_right = tkinter.Frame(app) + frame_right.pack(side=tkinter.TOP, expand=tkinter.TRUE, fill=tkinter.Y) + + top_frame = tkinter.Frame(frame_right) + top_frame.pack(fill=tkinter.X) + + tkinter.Button(top_frame, text="Open Image(s)", command=on_open_button).pack( + side=tkinter.LEFT, expand=tkinter.TRUE, fill=tkinter.X + ) + tkinter.Button(top_frame, text="Help", state=tkinter.DISABLED).pack( + side=tkinter.RIGHT, expand=tkinter.TRUE, fill=tkinter.X + ) + + frame1 = VerticalScrolledFrame(frame_right) + frame1.pack(side=tkinter.TOP, expand=tkinter.TRUE, fill=tkinter.Y) + + output_options = tkinter.LabelFrame(frame1.interior, text="Output Options") + output_options.pack(side=tkinter.TOP, expand=tkinter.TRUE, fill=tkinter.X) + tkinter.Label(output_options, text="colorspace").grid( + row=0, column=0, sticky=tkinter.W + ) + OptionMenu(output_options, args["colorspace"], "auto", state=tkinter.DISABLED).grid( + row=0, column=1, sticky=tkinter.W + ) + tkinter.Checkbutton( + output_options, + text="Suppress timestamp", + variable=args["nodate"], + state=tkinter.DISABLED, + ).grid(row=1, column=0, columnspan=2, sticky=tkinter.W) + tkinter.Checkbutton( + output_options, + text="without pdfrw", + variable=args["without_pdfrw"], + state=tkinter.DISABLED, + ).grid(row=2, column=0, columnspan=2, sticky=tkinter.W) + tkinter.Checkbutton( + output_options, + text="only first frame", + variable=args["first_frame_only"], + state=tkinter.DISABLED, + ).grid(row=3, column=0, columnspan=2, sticky=tkinter.W) + tkinter.Checkbutton( + output_options, text="force large input", state=tkinter.DISABLED + ).grid(row=4, column=0, columnspan=2, sticky=tkinter.W) + image_size_frame = tkinter.LabelFrame(frame1.interior, text="Image size") + image_size_frame.pack(side=tkinter.TOP, expand=tkinter.TRUE, fill=tkinter.X) + OptionMenu( + image_size_frame, + args["imgsize_dropdown"], + *(["auto", "custom"] + sorted(papernames.values())), + state=tkinter.DISABLED, + ).grid(row=1, column=0, columnspan=3, sticky=tkinter.W) + + tkinter.Label( + image_size_frame, text="Width:", state=tkinter.DISABLED, name="size_label_width" + ).grid(row=2, column=0, sticky=tkinter.W) + tkinter.Spinbox( + image_size_frame, + format="%.2f", + increment=0.01, + from_=0, + to=100, + width=5, + state=tkinter.DISABLED, + name="spinbox_width", + ).grid(row=2, column=1, sticky=tkinter.W) + tkinter.Label( + image_size_frame, text="mm", state=tkinter.DISABLED, name="size_label_width_mm" + ).grid(row=2, column=2, sticky=tkinter.W) + + tkinter.Label( + image_size_frame, + text="Height:", + state=tkinter.DISABLED, + name="size_label_height", + ).grid(row=3, column=0, sticky=tkinter.W) + tkinter.Spinbox( + image_size_frame, + format="%.2f", + increment=0.01, + from_=0, + to=100, + width=5, + state=tkinter.DISABLED, + name="spinbox_height", + ).grid(row=3, column=1, sticky=tkinter.W) + tkinter.Label( + image_size_frame, text="mm", state=tkinter.DISABLED, name="size_label_height_mm" + ).grid(row=3, column=2, sticky=tkinter.W) + + page_size_frame = tkinter.LabelFrame(frame1.interior, text="Page size") + page_size_frame.pack(side=tkinter.TOP, expand=tkinter.TRUE, fill=tkinter.X) + OptionMenu( + page_size_frame, + args["pagesize_dropdown"], + *(["auto", "custom"] + sorted(papernames.values())), + state=tkinter.DISABLED, + ).grid(row=1, column=0, columnspan=3, sticky=tkinter.W) + + tkinter.Label( + page_size_frame, text="Width:", state=tkinter.DISABLED, name="size_label_width" + ).grid(row=2, column=0, sticky=tkinter.W) + tkinter.Spinbox( + page_size_frame, + format="%.2f", + increment=0.01, + from_=0, + to=100, + width=5, + state=tkinter.DISABLED, + name="spinbox_width", + ).grid(row=2, column=1, sticky=tkinter.W) + tkinter.Label( + page_size_frame, text="mm", state=tkinter.DISABLED, name="size_label_width_mm" + ).grid(row=2, column=2, sticky=tkinter.W) + + tkinter.Label( + page_size_frame, + text="Height:", + state=tkinter.DISABLED, + name="size_label_height", + ).grid(row=3, column=0, sticky=tkinter.W) + tkinter.Spinbox( + page_size_frame, + format="%.2f", + increment=0.01, + from_=0, + to=100, + width=5, + state=tkinter.DISABLED, + name="spinbox_height", + ).grid(row=3, column=1, sticky=tkinter.W) + tkinter.Label( + page_size_frame, text="mm", state=tkinter.DISABLED, name="size_label_height_mm" + ).grid(row=3, column=2, sticky=tkinter.W) + layout_frame = tkinter.LabelFrame(frame1.interior, text="Layout") + layout_frame.pack(side=tkinter.TOP, expand=tkinter.TRUE, fill=tkinter.X) + tkinter.Label(layout_frame, text="border", state=tkinter.DISABLED).grid( + row=0, column=0, sticky=tkinter.W + ) + tkinter.Spinbox(layout_frame, state=tkinter.DISABLED).grid( + row=0, column=1, sticky=tkinter.W + ) + tkinter.Label(layout_frame, text="fit", state=tkinter.DISABLED).grid( + row=1, column=0, sticky=tkinter.W + ) + OptionMenu( + layout_frame, args["fit"], *[v.name for v in FitMode], state=tkinter.DISABLED + ).grid(row=1, column=1, sticky=tkinter.W) + tkinter.Checkbutton( + layout_frame, + text="auto orient", + state=tkinter.DISABLED, + variable=args["auto_orient"], + ).grid(row=2, column=0, columnspan=2, sticky=tkinter.W) + tkinter.Label(layout_frame, text="crop border", state=tkinter.DISABLED).grid( + row=3, column=0, sticky=tkinter.W + ) + tkinter.Spinbox(layout_frame, state=tkinter.DISABLED).grid( + row=3, column=1, sticky=tkinter.W + ) + tkinter.Label(layout_frame, text="bleed border", state=tkinter.DISABLED).grid( + row=4, column=0, sticky=tkinter.W + ) + tkinter.Spinbox(layout_frame, state=tkinter.DISABLED).grid( + row=4, column=1, sticky=tkinter.W + ) + tkinter.Label(layout_frame, text="trim border", state=tkinter.DISABLED).grid( + row=5, column=0, sticky=tkinter.W + ) + tkinter.Spinbox(layout_frame, state=tkinter.DISABLED).grid( + row=5, column=1, sticky=tkinter.W + ) + tkinter.Label(layout_frame, text="art border", state=tkinter.DISABLED).grid( + row=6, column=0, sticky=tkinter.W + ) + tkinter.Spinbox(layout_frame, state=tkinter.DISABLED).grid( + row=6, column=1, sticky=tkinter.W + ) + metadata_frame = tkinter.LabelFrame(frame1.interior, text="PDF metadata") + metadata_frame.pack(side=tkinter.TOP, expand=tkinter.TRUE, fill=tkinter.X) + tkinter.Label(metadata_frame, text="title", state=tkinter.DISABLED).grid( + row=0, column=0, sticky=tkinter.W + ) + tkinter.Entry( + metadata_frame, textvariable=args["title"], state=tkinter.DISABLED + ).grid(row=0, column=1, sticky=tkinter.W) + tkinter.Label(metadata_frame, text="author", state=tkinter.DISABLED).grid( + row=1, column=0, sticky=tkinter.W + ) + tkinter.Entry( + metadata_frame, textvariable=args["author"], state=tkinter.DISABLED + ).grid(row=1, column=1, sticky=tkinter.W) + tkinter.Label(metadata_frame, text="creator", state=tkinter.DISABLED).grid( + row=2, column=0, sticky=tkinter.W + ) + tkinter.Entry( + metadata_frame, textvariable=args["creator"], state=tkinter.DISABLED + ).grid(row=2, column=1, sticky=tkinter.W) + tkinter.Label(metadata_frame, text="producer", state=tkinter.DISABLED).grid( + row=3, column=0, sticky=tkinter.W + ) + tkinter.Entry( + metadata_frame, textvariable=args["producer"], state=tkinter.DISABLED + ).grid(row=3, column=1, sticky=tkinter.W) + tkinter.Label(metadata_frame, text="creation date", state=tkinter.DISABLED).grid( + row=4, column=0, sticky=tkinter.W + ) + tkinter.Entry( + metadata_frame, textvariable=args["creationdate"], state=tkinter.DISABLED + ).grid(row=4, column=1, sticky=tkinter.W) + tkinter.Label( + metadata_frame, text="modification date", state=tkinter.DISABLED + ).grid(row=5, column=0, sticky=tkinter.W) + tkinter.Entry( + metadata_frame, textvariable=args["moddate"], state=tkinter.DISABLED + ).grid(row=5, column=1, sticky=tkinter.W) + tkinter.Label(metadata_frame, text="subject", state=tkinter.DISABLED).grid( + row=6, column=0, sticky=tkinter.W + ) + tkinter.Entry(metadata_frame, state=tkinter.DISABLED).grid( + row=6, column=1, sticky=tkinter.W + ) + tkinter.Label(metadata_frame, text="keywords", state=tkinter.DISABLED).grid( + row=7, column=0, sticky=tkinter.W + ) + tkinter.Entry(metadata_frame, state=tkinter.DISABLED).grid( + row=7, column=1, sticky=tkinter.W + ) + viewer_frame = tkinter.LabelFrame(frame1.interior, text="PDF viewer options") + viewer_frame.pack(side=tkinter.TOP, expand=tkinter.TRUE, fill=tkinter.X) + tkinter.Label(viewer_frame, text="panes", state=tkinter.DISABLED).grid( + row=0, column=0, sticky=tkinter.W + ) + OptionMenu( + viewer_frame, + args["viewer_panes"], + *(["auto"] + [v.name for v in PageMode]), + state=tkinter.DISABLED, + ).grid(row=0, column=1, sticky=tkinter.W) + tkinter.Label(viewer_frame, text="initial page", state=tkinter.DISABLED).grid( + row=1, column=0, sticky=tkinter.W + ) + tkinter.Spinbox( + viewer_frame, + increment=1, + from_=1, + to=10000, + width=6, + textvariable=args["viewer_initial_page"], + state=tkinter.DISABLED, + name="viewer_initial_page_spinbox", + ).grid(row=1, column=1, sticky=tkinter.W) + tkinter.Label(viewer_frame, text="magnification", state=tkinter.DISABLED).grid( + row=2, column=0, sticky=tkinter.W + ) + OptionMenu( + viewer_frame, + args["viewer_magnification"], + *(["auto", "custom"] + [v.name for v in Magnification]), + state=tkinter.DISABLED, + ).grid(row=2, column=1, sticky=tkinter.W) + tkinter.Label(viewer_frame, text="page layout", state=tkinter.DISABLED).grid( + row=3, column=0, sticky=tkinter.W + ) + OptionMenu( + viewer_frame, + args["viewer_page_layout"], + *(["auto"] + [v.name for v in PageLayout]), + state=tkinter.DISABLED, + ).grid(row=3, column=1, sticky=tkinter.W) + tkinter.Checkbutton( + viewer_frame, + text="fit window to page size", + variable=args["viewer_fit_window"], + state=tkinter.DISABLED, + ).grid(row=4, column=0, columnspan=2, sticky=tkinter.W) + tkinter.Checkbutton( + viewer_frame, + text="center window", + variable=args["viewer_center_window"], + state=tkinter.DISABLED, + ).grid(row=5, column=0, columnspan=2, sticky=tkinter.W) + tkinter.Checkbutton( + viewer_frame, + text="open in fullscreen", + variable=args["viewer_fullscreen"], + state=tkinter.DISABLED, + ).grid(row=6, column=0, columnspan=2, sticky=tkinter.W) + + option_frame = tkinter.LabelFrame(frame1.interior, text="Program options") + option_frame.pack(side=tkinter.TOP, expand=tkinter.TRUE, fill=tkinter.X) + + tkinter.Label(option_frame, text="Unit:", state=tkinter.DISABLED).grid( + row=0, column=0, sticky=tkinter.W + ) + unit = tkinter.StringVar() + unit.set("mm") + OptionMenu(option_frame, unit, ["mm"], state=tkinter.DISABLED).grid( + row=0, column=1, sticky=tkinter.W + ) + + tkinter.Label(option_frame, text="Language:", state=tkinter.DISABLED).grid( + row=1, column=0, sticky=tkinter.W + ) + language = tkinter.StringVar() + language.set("English") + OptionMenu(option_frame, language, ["English"], state=tkinter.DISABLED).grid( + row=1, column=1, sticky=tkinter.W + ) + + bottom_frame = tkinter.Frame(frame_right) + bottom_frame.pack(fill=tkinter.X) + + tkinter.Button(bottom_frame, text="Save PDF", command=on_save_button).pack( + side=tkinter.LEFT, expand=tkinter.TRUE, fill=tkinter.X + ) + tkinter.Button(bottom_frame, text="Exit", command=root.destroy).pack( + side=tkinter.RIGHT, expand=tkinter.TRUE, fill=tkinter.X + ) + + app.mainloop() + + def main(argv=sys.argv): rendered_papersizes = "" for k, v in sorted(papersizes.items()): @@ -2321,6 +3085,23 @@ Report bugs at https://gitlab.mister-muffin.de/josch/img2pdf/issues version="%(prog)s " + __version__, help="Prints version information and exits.", ) + gui_group = parser.add_mutually_exclusive_group(required=False) + gui_group.add_argument( + "--gui", + dest="gui", + action="store_true", + help="run tkinter gui (default on Windows)", + ) + gui_group.add_argument( + "--nogui", + dest="gui", + action="store_false", + help="don't run tkinter gui (default elsewhere)", + ) + if platform.system() == "Windows": + parser.set_defaults(gui=True) + else: + parser.set_defaults(gui=False) outargs = parser.add_argument_group( title="General output arguments", @@ -2439,6 +3220,8 @@ the image size will be calculated from the page size, respecting the border setting. If the --border option is given while both the --pagesize and --imgsize options are passed, then the --border option will be ignored. +The --pagesize option or the --imgsize option with the --border option will +determine the MediaBox size of the resulting PDF document. """ % default_dpi, ) @@ -2506,6 +3289,50 @@ If both dimensions of the page are given via --pagesize, conditionally swaps these dimensions such that the page orientation is the same as the orientation of the input image. If the orientation of a page gets flipped, then so do the values set via the --border option. +""", + ) + sizeargs.add_argument( + "--crop-border", + metavar="L[:L]", + type=parse_borderarg, + help=""" +Specifies the border between the CropBox and the MediaBox. One, or two length +values can be given as an argument, separated by a colon. One value specifies +the border on all four sides. Two values specify the border on the top/bottom +and left/right, respectively. It is not possible to specify asymmetric borders. +""", + ) + sizeargs.add_argument( + "--bleed-border", + metavar="L[:L]", + type=parse_borderarg, + help=""" +Specifies the border between the BleedBox and the MediaBox. One, or two length +values can be given as an argument, separated by a colon. One value specifies +the border on all four sides. Two values specify the border on the top/bottom +and left/right, respectively. It is not possible to specify asymmetric borders. +""", + ) + sizeargs.add_argument( + "--trim-border", + metavar="L[:L]", + type=parse_borderarg, + help=""" +Specifies the border between the TrimBox and the MediaBox. One, or two length +values can be given as an argument, separated by a colon. One value specifies +the border on all four sides. Two values specify the border on the top/bottom +and left/right, respectively. It is not possible to specify asymmetric borders. +""", + ) + sizeargs.add_argument( + "--art-border", + metavar="L[:L]", + type=parse_borderarg, + help=""" +Specifies the border between the ArtBox and the MediaBox. One, or two length +values can be given as an argument, separated by a colon. One value specifies +the border on all four sides. Two values specify the border on the top/bottom +and left/right, respectively. It is not possible to specify asymmetric borders. """, ) @@ -2604,7 +3431,7 @@ values set via the --border option. viewerargs.add_argument( "--viewer-fit-window", action="store_true", - help="Instruct the PDF viewer to resize the window to fit the page " "size", + help="Instruct the PDF viewer to resize the window to fit the page size", ) viewerargs.add_argument( "--viewer-center-window", @@ -2625,6 +3452,10 @@ values set via the --border option. if args.pillow_limit_break: Image.MAX_IMAGE_PIXELS = None + if args.gui: + gui() + sys.exit(0) + layout_fun = get_layout_fun( args.pagesize, args.imgsize, args.border, args.fit, args.auto_orient ) @@ -2682,7 +3513,11 @@ values set via the --border option. viewer_fullscreen=args.viewer_fullscreen, with_pdfrw=not args.without_pdfrw, outputstream=args.output, - first_frame_only=args.first_frame_only + first_frame_only=args.first_frame_only, + cropborder=args.crop_border, + bleedborder=args.bleed_border, + trimborder=args.trim_border, + artborder=args.art_border, ) except Exception as e: logging.error("error: " + str(e)) diff --git a/test.sh b/test.sh index 5b34a30..013a041 100755 --- a/test.sh +++ b/test.sh @@ -90,48 +90,26 @@ tempdir=$(mktemp --directory --tmpdir img2pdf.XXXXXXXXXX) trap error EXIT -# we use -strip to remove all timestamps (tIME chunk and exif data) -convert -size 60x60 \( xc:none -fill red -draw 'circle 30,21 30,3' -gaussian-blur 0x3 \) \ - \( \( xc:none -fill lime -draw 'circle 39,39 36,57' -gaussian-blur 0x3 \) \ - \( xc:none -fill blue -draw 'circle 21,39 24,57' -gaussian-blur 0x3 \) \ - -compose plus -composite \ - \) -compose plus -composite \ - -strip \ - "$tempdir/alpha.png" - -convert "$tempdir/alpha.png" -background black -alpha remove -alpha off -strip "$tempdir/normal16.png" - -convert "$tempdir/normal16.png" -depth 8 -strip "$tempdir/normal.png" - -convert "$tempdir/normal.png" -negate -strip "$tempdir/inverse.png" - -convert "$tempdir/normal16.png" -colorspace Gray -depth 16 -strip "$tempdir/gray16.png" -convert "$tempdir/normal16.png" -colorspace Gray -dither FloydSteinberg -colors 256 -depth 8 -strip "$tempdir/gray8.png" -convert "$tempdir/normal16.png" -colorspace Gray -dither FloydSteinberg -colors 16 -depth 4 -strip "$tempdir/gray4.png" -convert "$tempdir/normal16.png" -colorspace Gray -dither FloydSteinberg -colors 4 -depth 2 -strip "$tempdir/gray2.png" -convert "$tempdir/normal16.png" -colorspace Gray -dither FloydSteinberg -colors 2 -depth 1 -strip "$tempdir/gray1.png" - -# use "-define png:exclude-chunk=bkgd" because otherwise, imagemagick will -# add the background color (white) as an additional entry to the palette -convert "$tempdir/normal.png" -dither FloydSteinberg -colors 2 -define png:exclude-chunk=bkgd -strip "$tempdir/palette1.png" -convert "$tempdir/normal.png" -dither FloydSteinberg -colors 4 -define png:exclude-chunk=bkgd -strip "$tempdir/palette2.png" -convert "$tempdir/normal.png" -dither FloydSteinberg -colors 16 -define png:exclude-chunk=bkgd -strip "$tempdir/palette4.png" -convert "$tempdir/normal.png" -dither FloydSteinberg -colors 256 -define png:exclude-chunk=bkgd -strip "$tempdir/palette8.png" +# instead of using imagemagick to craft the test input, we use a custom python +# script. This is because the output of imagemagick is not bit-by-bit identical +# across versions and architectures. +# See https://gitlab.mister-muffin.de/josch/img2pdf/issues/56 +python3 magick.py "$tempdir" cat << END | ( cd "$tempdir"; md5sum --check --status - ) -a99ef2a356c315090b6939fa4ce70516 alpha.png -0df21ebbce5292654119b17f6e52bc81 gray16.png -6faee81b8db446caa5004ad71bddcb5b gray1.png -97e423da517ede069348484a1283aa6c gray2.png -cbed1b6da5183aec0b86909e82b77c41 gray4.png -c0df42fdd69ae2a16ad0c23adb39895e gray8.png -ac6bb850fb5aaee9fa7dcb67525cd0fc inverse.png -3f3f8579f5054270e79a39e7cc4e89e0 normal16.png -cbe63b21443af8321b213bde6666951f normal.png -2f00705cca05fd94406fc39ede4d7322 palette1.png -6cb250d1915c2af99c324c43ff8286eb palette2.png -ab7b3d3907a851692ee36f5349ed0b2c palette4.png -03829af4af8776adf56ba2e68f5b111e palette8.png +7ed200c092c726c68e889514fff0d8f1 alpha.png +bf56e00465b98fb738f6edd2c58dac3b gray16.png +f93c3e3c11dad3f8c11db4fd2d01c2cc gray1.png +d63167c66e8a65bd5c15f68c8d554c48 gray2.png +6bceb845d9c9946adad1526954973945 gray4.png +7ba8152b9146eb7d9d50529189baf7db gray8.png +75130ec7635919e40c7396d45899ddbe inverse.png +bd1a2c9f9dfc51a827eafa3877cf7f83 normal16.png +329eac79fec2e1bc30d7a50ba2e3f2a5 normal.png +9ffd3f592b399f9f9c23892db82369cd palette1.png +6d3a39fe5f2efea5975f048d11a5cb02 palette2.png +57add39e5c278249b64ab23314a41c39 palette4.png +192a3b298d812a156e6f248238d2bb52 palette8.png END # use img2pdfprog environment variable if it is set -- cgit v1.2.3