Convert 8-bit PNG & GIF alpha channels to /SMasks in PDF #106

Merged
josch merged 7 commits from tzahola/img2pdf:png-alpha-to-smask into main 3 years ago

While it's true that PDF /Image objects don't support transparency, we can emulate it by splitting the alpha channel into a separate grayscale image (i.e. /DeviceGray), and apply it as a soft-mask (i.e. /SMask) on the opaque image.

In this PR I'm proposing adding this feature to img2pdf in case of 8-bit true color PNGs for 8-bit RGBA, LA (i.e. greyscale + alpha) and palletted images with transparency (e.g. GIFs):

  • The 8-bit limitation comes from the fact that PIL doesn't support multichannel images of higher bitdepths.
  • TIFF files can contain an alpha channel, too. However, separating it from the rest of the color components cannot be done losslessly in case of the premultiplied variant, so I didn't bother with it.
While it's true that PDF `/Image` objects don't support transparency, we can emulate it by splitting the alpha channel into a separate grayscale image (i.e. `/DeviceGray`), and apply it as a soft-mask (i.e. `/SMask`) on the opaque image. In this PR I'm proposing adding this feature to `img2pdf` ~~*in case of 8-bit true color PNGs*~~ *for 8-bit RGBA, LA (i.e. greyscale + alpha) and palletted images with transparency (e.g. GIFs)*: - The 8-bit limitation comes from the fact that PIL doesn't support multichannel images of higher bitdepths. - TIFF files can contain an alpha channel, too. However, separating it from the rest of the color components cannot be done losslessly in case of the premultiplied variant, so I didn't bother with it.
josch commented 3 years ago
Owner

Interesting. I'm considering it. The only thing that makes me feel a bit iffy about this is, that there is no way that I know of, to get the original image with alpha channel back from a pdf with a soft-mask. Do you know how to?

Furthermore:

  • can you add test cases?
  • tiff is odd, so i'm okay for not supporting it -- but how about gif?
  • if I read it correctly, you are currently just storing the zipped rgb stream -- why not apply png paeth filter for better compression first?
Interesting. I'm considering it. The only thing that makes me feel a bit iffy about this is, that there is no way that I know of, to get the original image with alpha channel back from a pdf with a soft-mask. Do you know how to? Furthermore: - can you add test cases? - tiff is odd, so i'm okay for not supporting it -- but how about gif? - if I read it correctly, you are currently just storing the zipped rgb stream -- why not apply png paeth filter for better compression first?
Poster

The only thing that makes me feel a bit iffy about this is, that there is no way that I know of, to get the original image with alpha channel back from a pdf with a soft-mask.

Yeah, pdfimages from Poppler will extract the soft-mask as a separate image. On the other hand, I've found that even for opaque images, pdfimages won't recover the original pixel data if an image is using anything but the device-dependent color profiles: https://gitlab.freedesktop.org/poppler/poppler/-/issues/1084

Personally, I'm using the attached pdf2img (attached as txt) shell script to losslessly recover images from PDF files (depends on qpdf, jq and convert from ImageMagick). Usage:

pdf2img mypdf.pdf output_folder

It's not as sophisticated as img2pdf, but can be used to verify that the conversion is indeed lossless.

can you add test cases?

Yeah, I'll look into it :)

tiff is odd, so i'm okay for not supporting it -- but how about gif?

I guess it could work too, but I need to check how ImageMagick deals with reconstructing a GIF from RGB data + bi-level alpha data (i.e. consisting solely of 0x00 an 0xFF). There's also another way of doing 1-bit transparency via the /Mask property, but I don't think using that would worth the additional complexity.

if I read it correctly, you are currently just storing the zipped rgb stream -- why not apply png paeth filter for better compression first?

You mean like this? :)

            # cheapo version to retrieve a PNG encoding of the payload is to
            # just save it with PIL. In the future this could be replaced by
            # dedicated function applying the Paeth PNG filter to the raw pixel
            pngbuffer = BytesIO()
            newimg.save(pngbuffer, format="png")
            pngidat, palette = parse_png(pngbuffer.getvalue())
>The only thing that makes me feel a bit iffy about this is, that there is no way that I know of, to get the original image with alpha channel back from a pdf with a soft-mask. Yeah, `pdfimages` from Poppler will extract the soft-mask as a separate image. On the other hand, I've found that even for opaque images, `pdfimages` won't recover the original pixel data if an image is using anything but the device-dependent color profiles: https://gitlab.freedesktop.org/poppler/poppler/-/issues/1084 Personally, I'm using the attached `pdf2img` (attached as txt) shell script to losslessly recover images from PDF files (depends on `qpdf`, `jq` and `convert` from ImageMagick). Usage: ``` pdf2img mypdf.pdf output_folder ``` It's not as sophisticated as `img2pdf`, but can be used to verify that the conversion is indeed lossless. >can you add test cases? Yeah, I'll look into it :) >tiff is odd, so i'm okay for not supporting it -- but how about gif? I guess it could work too, but I need to check how ImageMagick deals with reconstructing a GIF from RGB data + bi-level alpha data (i.e. consisting solely of 0x00 an 0xFF). There's also another way of doing 1-bit transparency via the `/Mask` property, but I don't think using that would worth the additional complexity. >if I read it correctly, you are currently just storing the zipped rgb stream -- why not apply png paeth filter for better compression first? You mean like this? :) ``` # cheapo version to retrieve a PNG encoding of the payload is to # just save it with PIL. In the future this could be replaced by # dedicated function applying the Paeth PNG filter to the raw pixel pngbuffer = BytesIO() newimg.save(pngbuffer, format="png") pngidat, palette = parse_png(pngbuffer.getvalue()) ```

The only thing that makes me feel a bit iffy about this is, that there is no way that I know of, to get the original image with alpha channel back from a pdf with a soft-mask.

This code snippet works for one of my documents to extract an image with SMask. The resulting image is visually identical to the original, though compositing might be lossy.
However, I think the shell script by @tzahola is better, since it is lossless and more general, but I wanted to share the approach how it could be done with pikepdf/PIL.

#! /usr/bin/env python3

# SPDX-FileCopyrightText: 2021 mara0004
# SPDX-License-Identifier: MPL-2.0

import os.path
import pikepdf
from PIL import Image

if __name__ == '__main__':
    
    # extract and save image with alpha mask
    
    DOCS = os.path.expanduser('~') + '/Dokumente/'

    doc = pikepdf.Pdf.open(DOCS+'out08.pdf')
    page = doc.pages[0]

    content = page['/Resources']['/XObject']['/FX1']['/Resources']['/XObject']

    non_alpha = content['/Im']
    print(repr(non_alpha))
    img_non_alpha = pikepdf.PdfImage(non_alpha)
    img_non_alpha = img_non_alpha.as_pil_image()

    alpha = content['/Im']['/SMask']
    img_alpha = pikepdf.PdfImage(alpha).as_pil_image()

    img_non_alpha.save(DOCS+'img_non_alpha.png')
    img_alpha.save(DOCS+'img_alpha.png')

    transparency = Image.new('LA', img_non_alpha.size, (0,0))
    composite = Image.composite(img_non_alpha, transparency, img_alpha)
    composite.save(DOCS+'img_composite.png')
> The only thing that makes me feel a bit iffy about this is, that there is no way that I know of, to get the original image with alpha channel back from a pdf with a soft-mask. This code snippet works for one of my documents to extract an image with SMask. The resulting image is visually identical to the original, though compositing might be lossy. However, I think the shell script by @tzahola is better, since it is lossless and more general, but I wanted to share the approach how it could be done with pikepdf/PIL. ```python #! /usr/bin/env python3 # SPDX-FileCopyrightText: 2021 mara0004 # SPDX-License-Identifier: MPL-2.0 import os.path import pikepdf from PIL import Image if __name__ == '__main__': # extract and save image with alpha mask DOCS = os.path.expanduser('~') + '/Dokumente/' doc = pikepdf.Pdf.open(DOCS+'out08.pdf') page = doc.pages[0] content = page['/Resources']['/XObject']['/FX1']['/Resources']['/XObject'] non_alpha = content['/Im'] print(repr(non_alpha)) img_non_alpha = pikepdf.PdfImage(non_alpha) img_non_alpha = img_non_alpha.as_pil_image() alpha = content['/Im']['/SMask'] img_alpha = pikepdf.PdfImage(alpha).as_pil_image() img_non_alpha.save(DOCS+'img_non_alpha.png') img_alpha.save(DOCS+'img_alpha.png') transparency = Image.new('LA', img_non_alpha.size, (0,0)) composite = Image.composite(img_non_alpha, transparency, img_alpha) composite.save(DOCS+'img_composite.png') ```
148 KiB
Poster

Tweaked the code a bit, and added support for transparent GIFs, and grayscale + alpha PNGs. Also, both the SMask and the RGB part uses the PNG predictor.

Tweaked the code a bit, and added support for transparent GIFs, and grayscale + alpha PNGs. Also, both the SMask and the RGB part uses the PNG predictor.
tzahola changed title from Convert 8-bit PNG alpha channels to /SMasks in PDF to Convert 8-bit PNG & GIF alpha channels to /SMasks in PDF 3 years ago
Poster

I'm writing some test cases (8dac6242fc), and noticed that most of them are disabled on macOS. I've tried enabling them, and with a few exceptions they worked fine (given that the dependencies were installed via Homebrew), so I've removed the sys.platform limitation from those (9cd5121477).

However, I'm encountering an issue where some TIFF test cases are checking the depth field reported by ImageMagick differently, depending on its version. Namely, this one:

    if identify[0].get("version", "0") < "1.0":
        assert identify[0]["image"].get("depth") == 12, str(identify)
    else:
        assert identify[0]["image"].get("depth") == 16, str(identify)

I have ImageMagick 7.1.0-0 Q16 installed, which reports 1.0 as the version field in the JSON output, but both the depth and baseDepth fields have the same value, which is 12 in this case. This seems to have been introduced recently by @josch in 454d4e7775, so I'm wondering if you could help me with some context on this conditional? Maybe it should be the other way around? Or should be <= instead of < ? I'd rather not tear down my env to install some older version of ImageMagick unless absolutely necessary... :D

image

I'm writing some test cases (https://gitlab.mister-muffin.de/josch/img2pdf/commit/8dac6242fc9000f3e2b2dde01ea62a0e48ac6619), and noticed that most of them are disabled on macOS. I've tried enabling them, and with a few exceptions they worked fine (given that the dependencies were installed via Homebrew), so I've removed the `sys.platform` limitation from those (https://gitlab.mister-muffin.de/josch/img2pdf/commit/9cd512147732c214a5f4f197f45f604762d2699b). However, I'm encountering an issue where some TIFF test cases are checking the `depth` field reported by ImageMagick differently, depending on its version. Namely, this one: ``` if identify[0].get("version", "0") < "1.0": assert identify[0]["image"].get("depth") == 12, str(identify) else: assert identify[0]["image"].get("depth") == 16, str(identify) ``` I have `ImageMagick 7.1.0-0 Q16` installed, which reports `1.0` as the `version` field in the JSON output, but both the `depth` and `baseDepth` fields have the same value, which is `12` in this case. This seems to have been introduced recently by @josch in https://gitlab.mister-muffin.de/josch/img2pdf/commit/454d4e77753f3dd14ab82018d77121c284bb2e74, so I'm wondering if you could help me with some context on this conditional? Maybe it should be the other way around? Or should be `<=` instead of `<` ? I'd rather not tear down my env to install some older version of ImageMagick unless absolutely necessary... :D ![image](/attachments/ac563968-69a3-4229-998b-99d7dfec2212)
1.4 MiB

I am wondering whether converting to RGBA is really lossless? I know very little about images...

else:
  newcolor = Colorspace.RGBA
  r, g, b, a = newimg.convert(mode="RGBA").split()
  newimg = Image.merge("RGB", (r, g, b))
I am wondering whether converting to RGBA is really lossless? I know very little about images... ```python else: newcolor = Colorspace.RGBA r, g, b, a = newimg.convert(mode="RGBA").split() newimg = Image.merge("RGB", (r, g, b)) ```
Poster

I am wondering whether converting to RGBA is really lossless? I know very little about images...

else:
  newcolor = Colorspace.RGBA
  r, g, b, a = newimg.convert(mode="RGBA").split()
  newimg = Image.merge("RGB", (r, g, b))

The only way we can end up here is if we have a palette-based PNG or GIF with transparency. The palette is basically a lookup table of RGB values, and the pixels are indices into this table. So converting to RGBA will simply look up the color value for each pixel from the palette. There should be no interpolation, color space conversion, etc. It just removes a layer of indirection.

> I am wondering whether converting to RGBA is really lossless? I know very little about images... > > ```python > else: > newcolor = Colorspace.RGBA > r, g, b, a = newimg.convert(mode="RGBA").split() > newimg = Image.merge("RGB", (r, g, b)) > ``` The only way we can end up here is if we have a palette-based PNG or GIF with transparency. The palette is basically a lookup table of RGB values, and the pixels are indices into this table. So converting to RGBA will simply look up the color value for each pixel from the palette. There should be no interpolation, color space conversion, etc. It just removes a layer of indirection.

Thanks for explaining!

Thanks for explaining!

In case @josch decides to merge this PR, how would I detect whether img2pdf can handle the image, or whether the alpha channel would still need to be removed?

In case @josch decides to merge this PR, how would I detect whether img2pdf can handle the image, or whether the alpha channel would still need to be removed?
Poster

In case @josch decides to merge this PR, how would I detect whether img2pdf can handle the image, or whether the alpha channel would still need to be removed?
(Currently I just check if the image mode is in (RGBA, LA, PA) or if there is the 'transparency' key in the info dictionary.)

"RGBA" (e.g. RGB PNG with alpha), "LA" (e.g. greyscale PNG with alpha), and "P + transparency in the info dict" (e.g. GIF with transparency) will work fine. I haven't yet seen a file that reported "PA" - do you have a sample file maybe for this?

> In case @josch decides to merge this PR, how would I detect whether img2pdf can handle the image, or whether the alpha channel would still need to be removed? > (Currently I just check if the image mode is in (RGBA, LA, PA) or if there is the 'transparency' key in the info dictionary.) "RGBA" (e.g. RGB PNG with alpha), "LA" (e.g. greyscale PNG with alpha), and "P + transparency in the info dict" (e.g. GIF with transparency) will work fine. I haven't yet seen a file that reported "PA" - do you have a sample file maybe for this?

I don't have a sample file, I just found that mode in the pillow docs, under the section "limited support for a few additional modes" (https://pillow.readthedocs.io/en/stable/handbook/concepts.html#concept-modes).

I don't have a sample file, I just found that mode in the pillow docs, under the section "limited support for a few additional modes" (https://pillow.readthedocs.io/en/stable/handbook/concepts.html#concept-modes).
Poster

Yeah, I saw that too. To me it sounds like some ultra-esoteric use case, i.e. you use low-res paletted color (like GIF), but a full resolution alpha channel... (?) I wouldn't really bother supporting it.

So, the answer to your original question is, the file is not supported if image.mode == "PA". 16-bit images with alpha channels are not supported either due to PIL being unable to properly deal with >8bit images, but since you're using PIL to validate the file, you can't detect them either, as PIL will e.g. report a 16-bit RGBA image simply as "L"... (img2pdf uses tricks like reading PNG header bytes to detect these cases)

Yeah, I saw that too. To me it sounds like some ultra-esoteric use case, i.e. you use low-res paletted color (like GIF), but a full resolution alpha channel... (?) I wouldn't really bother supporting it. So, the answer to your original question is, the file is not supported if `image.mode == "PA"`. 16-bit images with alpha channels are not supported either due to PIL being unable to properly deal with >8bit images, but since you're using PIL to validate the file, you can't detect them either, as PIL will e.g. report a 16-bit RGBA image simply as "L"... (`img2pdf` uses tricks like reading PNG header bytes to detect these cases)

@josch What's your opinion on this PR now?

@josch What's your opinion on this PR now?
josch commented 3 years ago
Owner

I love it. This will definitely be part of the next img2pdf release. I'll probably name it 0.5.0 because support for transparancey is a major new feature. Unfortunately, I spent most of my time these days caring for my newborn daughter, so it might take a while before I get to merge your pull request. Thank you for your work!

I love it. This will definitely be part of the next img2pdf release. I'll probably name it 0.5.0 because support for transparancey is a major new feature. Unfortunately, I spent most of my time these days caring for my newborn daughter, so it might take a while before I get to merge your pull request. Thank you for your work!
Poster

No worries! :) Congratulations! 👶

No worries! :) Congratulations! 👶

From me too, congratulations :)
Take your time with the release, family is much more important than software

From me too, congratulations :) Take your time with the release, family is much more important than software
josch commented 3 years ago
Owner

Hi,

could you rebase your commits on top of the current main branch? I merged some of the other merge requests which should fix compatibility with imagemagick 7 that you are running.

Would you be around to fix MacOS problems in case they happen on our CI?

https://travis-ci.com/github/josch/img2pdf

Thanks!

cheers, josch

Hi, could you rebase your commits on top of the current main branch? I merged some of the other merge requests which should fix compatibility with imagemagick 7 that you are running. Would you be around to fix MacOS problems in case they happen on our CI? https://travis-ci.com/github/josch/img2pdf Thanks! cheers, josch
Poster

Hi,

could you rebase your commits on top of the current main branch? I merged some of the other merge requests which should fix compatibility with imagemagick 7 that you are running.

Would you be around to fix MacOS problems in case they happen on our CI?

https://travis-ci.com/github/josch/img2pdf

Thanks!

cheers, josch

Sure!

  • Rebased on main
  • Sure, I can fix any macOS issues if they occur! Right now the only issue I experience locally is with these 12-bit and 14-bit TIFF test cases. Could you please take a look at that, i.e. why are we checking the reported bit depth that way, based on the ImageMagick version?
> Hi, > > could you rebase your commits on top of the current main branch? I merged some of the other merge requests which should fix compatibility with imagemagick 7 that you are running. > > Would you be around to fix MacOS problems in case they happen on our CI? > > https://travis-ci.com/github/josch/img2pdf > > Thanks! > > cheers, josch Sure! - Rebased on `main` :white_check_mark: - Sure, I can fix any macOS issues if they occur! Right now the only issue I experience locally is with [these 12-bit and 14-bit TIFF test cases](https://gitlab.mister-muffin.de/josch/img2pdf/issues/106#issuecomment-233). Could you please take a look at that, i.e. why are we checking the reported bit depth that way, based on the ImageMagick version?
josch merged commit f483638b17 into main 3 years ago
josch commented 3 years ago
Owner

However, I'm encountering an issue where some TIFF test cases are checking the depth field reported by ImageMagick differently, depending on its version. Namely, this one:

    if identify[0].get("version", "0") < "1.0":
        assert identify[0]["image"].get("depth") == 12, str(identify)
    else:
        assert identify[0]["image"].get("depth") == 16, str(identify)

To find the reason for this, run the following:

$ convert rose: -depth 12 out.tiff
$ convert out.tiff json: | grep -i depth
    "depth": 16,                
    "baseDepth": 12,

This is essentially what happens in the function tiff_rgb12_img and we check the depth to make sure that the test input we created has the expected bit depth.

What happens if you run the commands above?

> However, I'm encountering an issue where some TIFF test cases are checking the `depth` field reported by ImageMagick differently, depending on its version. Namely, this one: > > ``` > if identify[0].get("version", "0") < "1.0": > assert identify[0]["image"].get("depth") == 12, str(identify) > else: > assert identify[0]["image"].get("depth") == 16, str(identify) > ``` To find the reason for this, run the following: ``` $ convert rose: -depth 12 out.tiff $ convert out.tiff json: | grep -i depth "depth": 16, "baseDepth": 12, ``` This is essentially what happens in the function `tiff_rgb12_img` and we check the depth to make sure that the test input we created has the expected bit depth. What happens if you run the commands above?
Poster

To find the reason for this, run the following:

$ convert rose: -depth 12 out.tiff
$ convert out.tiff json: | grep -i depth
    "depth": 16,                
    "baseDepth": 12,

This is essentially what happens in the function tiff_rgb12_img and we check the depth to make sure that the test input we created has the expected bit depth.

What happens if you run the commands above?

For me it outputs the following:

$ convert rose: -depth 12 out.tiff
$ convert out.tiff json: | grep -i depth
    "depth": 12,
    "baseDepth": 12,
    "channelDepth": {

Which is why these TIFF test cases are failing for me (i.e. depth != 16, while version == "1.0").

This is my ImageMagick version:

$ convert --version
Version: ImageMagick 7.1.0-4 Q16 x86_64 2021-07-18 https://imagemagick.org
Copyright: (C) 1999-2021 ImageMagick Studio LLC
License: https://imagemagick.org/script/license.php
Features: Cipher DPC HDRI Modules OpenMP(5.0) 
Delegates (built-in): bzlib fontconfig freetype gslib heic jng jp2 jpeg lcms lqr ltdl lzma openexr png ps tiff webp xml zlib

With which ImageMagick version do you get depth: 16?

If this is a difference between ImageMagick versions, then I think using the JSON schema version (i.e. version < "1.0") is incorrect, and it should be done based on the version of ImageMagick itself instead. The schema version should be used for things like the endianess vs. endianness thing.

> To find the reason for this, run the following: > > ``` > $ convert rose: -depth 12 out.tiff > $ convert out.tiff json: | grep -i depth > "depth": 16, > "baseDepth": 12, > ``` > > This is essentially what happens in the function `tiff_rgb12_img` and we check the depth to make sure that the test input we created has the expected bit depth. > > What happens if you run the commands above? For me it outputs the following: ``` $ convert rose: -depth 12 out.tiff $ convert out.tiff json: | grep -i depth "depth": 12, "baseDepth": 12, "channelDepth": { ``` Which is why these TIFF test cases are failing for me (i.e. `depth != 16`, while `version == "1.0"`). This is my ImageMagick version: ``` $ convert --version Version: ImageMagick 7.1.0-4 Q16 x86_64 2021-07-18 https://imagemagick.org Copyright: (C) 1999-2021 ImageMagick Studio LLC License: https://imagemagick.org/script/license.php Features: Cipher DPC HDRI Modules OpenMP(5.0) Delegates (built-in): bzlib fontconfig freetype gslib heic jng jp2 jpeg lcms lqr ltdl lzma openexr png ps tiff webp xml zlib ``` With which ImageMagick version do you get `depth: 16`? If this is a difference between ImageMagick versions, then I think using the JSON schema version (i.e. `version < "1.0"`) is incorrect, and it should be done based on the version of ImageMagick itself instead. The schema version should be used for things like the `endianess` vs. `endianness` thing.
josch commented 3 years ago
Owner

This is my ImageMagick version:

$ convert --version
Version: ImageMagick 7.1.0-4 Q16 x86_64 2021-07-18 https://imagemagick.org
Copyright: (C) 1999-2021 ImageMagick Studio LLC
License: https://imagemagick.org/script/license.php
Features: Cipher DPC HDRI Modules OpenMP(5.0) 
Delegates (built-in): bzlib fontconfig freetype gslib heic jng jp2 jpeg lcms lqr ltdl lzma openexr png ps tiff webp xml zlib

With which ImageMagick version do you get depth: 16?

I'm currently using 6.9.11.

If this is a difference between ImageMagick versions, then I think using the JSON schema version (i.e. version < "1.0") is incorrect, and it should be done based on the version of ImageMagick itself instead. The schema version should be used for things like the endianess vs. endianness thing.

Yes, that's probably correct. All of this will probably be a thing of the past in a couple more months, when imagemagick 7 is available everywhere.

> This is my ImageMagick version: > > ``` > $ convert --version > Version: ImageMagick 7.1.0-4 Q16 x86_64 2021-07-18 https://imagemagick.org > Copyright: (C) 1999-2021 ImageMagick Studio LLC > License: https://imagemagick.org/script/license.php > Features: Cipher DPC HDRI Modules OpenMP(5.0) > Delegates (built-in): bzlib fontconfig freetype gslib heic jng jp2 jpeg lcms lqr ltdl lzma openexr png ps tiff webp xml zlib > ``` > > With which ImageMagick version do you get `depth: 16`? I'm currently using 6.9.11. > If this is a difference between ImageMagick versions, then I think using the JSON schema version (i.e. `version < "1.0"`) is incorrect, and it should be done based on the version of ImageMagick itself instead. The schema version should be used for things like the `endianess` vs. `endianness` thing. Yes, that's probably correct. All of this will probably be a thing of the past in a couple more months, when imagemagick 7 is available everywhere.
The pull request has been merged as f483638b17.
You can also view command line instructions.

Step 1:

From your project repository, check out a new branch and test the changes.
git checkout -b tzahola-png-alpha-to-smask main
git pull png-alpha-to-smask

Step 2:

Merge the changes and update on Gitea.
git checkout main
git merge --no-ff tzahola-png-alpha-to-smask
git push origin main
Sign in to join this conversation.
No reviewers
No Milestone
No project
No Assignees
3 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: josch/img2pdf#106
Loading…
There is no content yet.