From 8db9ae1cf158a15e0960c2968a0de390623bd1c9 Mon Sep 17 00:00:00 2001 From: Joseph Lewis III Date: Sun, 3 Nov 2024 13:50:39 -0800 Subject: [PATCH] Added support for customizing ZIM logo --- CHANGELOG.md | 2 +- README.md | 2 ++ src/devdocs2zim/constants.py | 1 + src/devdocs2zim/entrypoint.py | 2 ++ src/devdocs2zim/generator.py | 59 +++++++++++++++++++++++++++++++--- tests/test_generator.py | 45 ++++++++++++++++++++++++++ tests/testdata/test.jpg | Bin 0 -> 1922 bytes tests/testdata/test.png | Bin 0 -> 2746 bytes tests/testdata/test.svg | 7 ++++ 9 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 tests/testdata/test.jpg create mode 100644 tests/testdata/test.png create mode 100644 tests/testdata/test.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 05e46a2..1d086b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] - ### Added - Syntax highlighting matching DevDocs. (#30) +- Support for setting a custom icon for produced ZIM files. (#32) ### Changed diff --git a/README.md b/README.md index 5365365..b716d4b 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,8 @@ docker run -v my_dir:/output ghcr.io/openzim/devdocs devdocs2zim --first=2 Value will be truncated to 4000 chars.Default: '{full_name} documentation by DevDocs' * `--tag TAG`: Add tag to the ZIM. Use --tag several times to add multiple. Formatting is supported. Default: ['devdocs', '{slug_without_version}'] +* `--logo-format FORMAT`: URL/path for the ZIM logo in PNG, JPG, or SVG format. + Formatting placeholders are supported. If unset, a DevDocs logo will be used. **Formatting Placeholders** diff --git a/src/devdocs2zim/constants.py b/src/devdocs2zim/constants.py index 5eb74f5..4ecd8b4 100644 --- a/src/devdocs2zim/constants.py +++ b/src/devdocs2zim/constants.py @@ -10,6 +10,7 @@ NAME = "devdocs2zim" VERSION = __version__ ROOT_DIR = pathlib.Path(__file__).parent +DEFAULT_LOGO_PATH = ROOT_DIR.joinpath("third_party", "devdocs", "devdocs_48.png") DEVDOCS_FRONTEND_URL = "https://devdocs.io" DEVDOCS_DOCUMENTS_URL = "https://documents.devdocs.io" diff --git a/src/devdocs2zim/entrypoint.py b/src/devdocs2zim/entrypoint.py index f3afa7e..4292b93 100644 --- a/src/devdocs2zim/entrypoint.py +++ b/src/devdocs2zim/entrypoint.py @@ -4,6 +4,7 @@ from devdocs2zim.client import DevdocsClient from devdocs2zim.constants import ( + DEFAULT_LOGO_PATH, DEVDOCS_DOCUMENTS_URL, DEVDOCS_FRONTEND_URL, NAME, @@ -24,6 +25,7 @@ def zim_defaults() -> ZimConfig: description_format="{full_name} docs by DevDocs", long_description_format=None, tags="devdocs;{slug_without_version}", + logo_format=str(DEFAULT_LOGO_PATH), ) diff --git a/src/devdocs2zim/generator.py b/src/devdocs2zim/generator.py index 76f568d..4d410ba 100644 --- a/src/devdocs2zim/generator.py +++ b/src/devdocs2zim/generator.py @@ -1,5 +1,6 @@ import argparse import datetime +import io import os import re from collections import defaultdict @@ -13,6 +14,17 @@ MAXIMUM_LONG_DESCRIPTION_METADATA_LENGTH, RECOMMENDED_MAX_TITLE_LENGTH, ) +from zimscraperlib.image.conversion import ( # pyright: ignore[reportMissingTypeStubs] + convert_image, + convert_svg2png, + format_for, +) +from zimscraperlib.image.transformation import ( # pyright: ignore[reportMissingTypeStubs] + resize_image, +) +from zimscraperlib.inputs import ( # pyright: ignore[reportMissingTypeStubs] + handle_user_provided_file, +) from zimscraperlib.zim import ( # pyright: ignore[reportMissingTypeStubs] Creator, StaticItem, @@ -21,7 +33,6 @@ IndexData, ) -# pyright: ignore[reportMissingTypeStubs] from devdocs2zim.client import ( DevdocsClient, DevdocsIndex, @@ -71,6 +82,8 @@ class ZimConfig(BaseModel): long_description_format: str | None # Semicolon delimited list of tags to apply to the ZIM. tags: str + # Format to use for the logo. + logo_format: str @staticmethod def add_flags(parser: argparse.ArgumentParser, defaults: "ZimConfig"): @@ -134,12 +147,21 @@ def add_flags(parser: argparse.ArgumentParser, defaults: "ZimConfig"): # argparse doesn't work so we expose the underlying semicolon delimited string. parser.add_argument( "--tags", - help="A semicolon (;) delimited list of tags to add to the ZIM." + help="A semicolon (;) delimited list of tags to add to the ZIM. " "Formatting is supported. " f"Default: {defaults.tags!r}", default=defaults.tags, ) + parser.add_argument( + "--logo-format", + help="URL/path for the ZIM logo in PNG, JPG, or SVG format. " + "Formatting placeholders are supported. " + "If unset, a DevDocs logo will be used.", + default=defaults.logo_format, + metavar="FORMAT", + ) + @staticmethod def of(namespace: argparse.Namespace) -> "ZimConfig": """Parses a namespace to create a new ZimConfig.""" @@ -195,6 +217,7 @@ def check_length(string: str, field_name: str, length: int) -> str: else None ), tags=fmt(self.tags), + logo_format=fmt(self.logo_format), ) @@ -339,7 +362,6 @@ def __init__( self.page_template = self.env.get_template("page.html") # type: ignore self.licenses_template = self.env.get_template(LICENSE_FILE) # type: ignore - self.logo_path = self.asset_path("devdocs_48.png") self.copyright_path = self.asset_path("COPYRIGHT") self.license_path = self.asset_path("LICENSE") @@ -456,6 +478,7 @@ def generate_zim( logger.info(f" Writing to: {zim_path}") + logo_bytes = self.fetch_logo_bytes(formatted_config.logo_format) creator = Creator(zim_path, "index") creator.config_metadata( Name=formatted_config.name_format, @@ -469,7 +492,7 @@ def generate_zim( Language=LANGUAGE_ISO_639_3, Tags=formatted_config.tags, Scraper=f"{NAME} v{VERSION}", - Illustration_48x48_at_1=self.logo_path.read_bytes(), + Illustration_48x48_at_1=logo_bytes, ) # Start creator early to detect problems early. @@ -491,6 +514,34 @@ def generate_zim( ) return zim_path + @staticmethod + def fetch_logo_bytes(user_logo_path: str) -> bytes: + """Fetch a user-supplied logo for the ZIM and format/resize it. + + Parameters: + user_logo_path: Path or URL to the logo. + """ + logger.info(f" Fetching logo from: {user_logo_path}") + full_logo_path = handle_user_provided_file(source=user_logo_path) + if full_logo_path is None: + # This appears to only happen if the path is blank. + raise Exception(f"Fetching logo {user_logo_path!r} failed.") + + converted_buf = io.BytesIO() + if format_for(full_logo_path, from_suffix=False) == "SVG": + # SVG conversion generates a PNG in the correct size + # so immediately return it. + convert_svg2png(full_logo_path, converted_buf, 48, 48) + return converted_buf.getvalue() + else: + # Convert to PNG + convert_image(full_logo_path, converted_buf, fmt="PNG") + + # resize to 48x48 + resized_buf = io.BytesIO() + resize_image(converted_buf, 48, 48, resized_buf, allow_upscaling=True) + return resized_buf.getvalue() + @staticmethod def page_titles(pages: list[DevdocsIndexEntry]) -> dict[str, str]: """Returns a map between page paths in the DB and their "best" title. diff --git a/tests/test_generator.py b/tests/test_generator.py index 5d976a5..454380e 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -1,9 +1,12 @@ import argparse +import io from pathlib import Path from tempfile import TemporaryDirectory from unittest import TestCase from unittest.mock import create_autospec +from PIL.Image import open as pilopen + from devdocs2zim.client import ( DevdocsClient, DevdocsIndex, @@ -32,6 +35,7 @@ def defaults(self) -> ZimConfig: description_format="default_description_format", long_description_format="default_long_description_format", tags="default_tag1;default_tag2", + logo_format="default_logo_format", ) def test_flag_parsing_defaults(self): @@ -66,6 +70,8 @@ def test_flag_parsing_overrides(self): "long-description-format", "--tags", "tag1;tag2", + "--logo-format", + "logo-format", ] ) ) @@ -80,6 +86,7 @@ def test_flag_parsing_overrides(self): description_format="description-format", long_description_format="long-description-format", tags="tag1;tag2", + logo_format="logo-format", ), got, ) @@ -101,6 +108,7 @@ def test_format_only_allowed(self): description_format="{replace_me}", long_description_format="{replace_me}", tags="{replace_me}", + logo_format="{replace_me}", ) got = to_format.format({"replace_me": "replaced"}) @@ -115,6 +123,7 @@ def test_format_only_allowed(self): description_format="replaced", long_description_format="replaced", tags="replaced", + logo_format="replaced", ), got, ) @@ -426,3 +435,39 @@ def test_page_titles_only_fragment(self): # First fragment wins if no page points to the top self.assertEqual({"mock": "Mock Sub1"}, got) + + def test_fetch_logo_bytes_jpeg(self): + jpg_path = str(Path(__file__).parent / "testdata" / "test.jpg") + + got = Generator.fetch_logo_bytes(jpg_path) + + self.assertIsNotNone(got) + with pilopen(io.BytesIO(got)) as image: + self.assertEqual((48, 48), image.size) + self.assertEqual("PNG", image.format) + + def test_fetch_logo_bytes_png(self): + png_path = str(Path(__file__).parent / "testdata" / "test.png") + + got = Generator.fetch_logo_bytes(png_path) + + self.assertIsNotNone(got) + with pilopen(io.BytesIO(got)) as image: + self.assertEqual((48, 48), image.size) + self.assertEqual("PNG", image.format) + + def test_fetch_logo_bytes_svg(self): + png_path = str(Path(__file__).parent / "testdata" / "test.svg") + + got = Generator.fetch_logo_bytes(png_path) + + self.assertIsNotNone(got) + with pilopen(io.BytesIO(got)) as image: + self.assertEqual((48, 48), image.size) + self.assertEqual("PNG", image.format) + + def test_fetch_logo_bytes_does_not_exist_fails(self): + self.assertRaises(OSError, Generator.fetch_logo_bytes, "does_not_exist") + + def test_fetch_logo_bytes_returns_none_fails(self): + self.assertRaises(Exception, Generator.fetch_logo_bytes, "") diff --git a/tests/testdata/test.jpg b/tests/testdata/test.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5c66b6d959e1bbe224adfc5e75e7de9fdc6068b3 GIT binary patch literal 1922 zcmb7EX;_n27QWy2MKBTKmyl@0r2!g5BOw8r5Y~iEP?AD|m5wNDfC!8b4YFC2pfWlr zAd48KAT0qB^kJ+O1s$SQsa8~|m8C!dl|@I2Qi=x{&b#sf1T&vbM8Ioea|_D zF2evoH~0to1IUPV&^iDNZ-Ir6OcJ$A5=-4J-IYLHA1DYm{0w{my2uP~hC}h@cp?F{ zpx9YbmMo#LY-lU(T%A1JxlS$|UO>2z=eyaD;}V=4vN<9;J|W&ykeZPan;tHSkC}S} zA`pobGR48t(jms%#XIKzhT$q8ECjJ27J&!=CO`-QGF$_e00M}y0`LJaf>{8W81G2` zb^Ol3&;!g62*45sAG1A2G#aDr!6Cbjq31FCfBB<4Ud7HmeJ>c$_>^~suki_B}Ommrq z9e_+Rw{3X;)m+v#C_n%VLl|Q&{{aLe07D>BQ412w#+k=7Cc4m=KC}RS+uE{x!;`Ks z28`bmDd#qdok{+l&WEzwxmQ9eU-gU6-6p4 zXI^Ojs*)E)3$p`E$>bD8ie0$PNt|x%S*52(uGi?*y|mg|;WcGz)u+TCHG3kv3h913 z2S4raTUqz~ac9u;gVLp^t9B;*em9@dYhb zKIJ7|y!CL2Z9i!p+CTEiqqvIhIr<6n#m(2A@~$eQTwc(cKXHKP9oC`%LJ)WX#z@W| zG#Qmgh*s9AD3#?HV!`7_VMI)s@(;F5yx{4>$wPNbs`dZ+EMSpIdX(M(bUvqUAw9yG zwxp=;HFpy7BoVLh$3Du(9>{4bvvRs$v2iTnV4vxYGlzy7-6qAg<6XkUputS}m|}2p zM<_zUzSVZ#8O^lbE~MHQByKI5YAdO(Ogc-81ZU5k!xJgplPlV-v-i@mwlDPpJa@O> z(j%59;f{fHUb@iS<8p>HsWzYHrwpxmO&WP1P4z&X)qi>ZTb+3JPG}f8YT!jjcSvLX zAph$(Gpo6?cO6fzV`zd@B(Y{lJ-nNFOBp!*a?_co?p3<(NCdYJjvOg2#d?x(7_#od zqc^`8Nwh_c?QCQTSuoEw1PE5FkSMCMke_-AW%8~}w9j{wm(|QI_tB@!;m?PrZsNY- zZeJA3p;UhRRasxwuH~9OmK<-FiPUMYdG}r{ag!V!V~9@wG>E}}yQ+N9{!o0eS(8)l z&J4IW4p+4+`9523m2CjubfmcmqO1Lzy?2aPCzfS{QqlJ*dV4X)d;iHKx*XvNj>i{0 zC|O*!qCo4Us>H!NJ(;-IBvyk%9r5N^Qj1)1J=yh^mKIfic3ed}oM&KVts#GW2| z717%$m;96Fdc-Uoqa8R{u2`ncy2yvmpU;?44D>-=0e7iZ+eEXljO@js{aJ7GhC9tdd5_hQ zeCudwh7#N0Gb!kh6pD|N_XL&&x3C`ev2#=@hmSqqaNS{t%x0PH*y*bu=P_o3w_}!2 zs!SfoCc542th% zufAs@60L`k-=s$@+m6IsUrvXwRL-zcbW(rA6&w80-5SZa&FB zhceL=GIDBR0LfhHR^F_0TiNF(o^n;M&}mi`KD6|Aw4D)v^QD;{rcEDnddJ76S`6L) E2CS#TWdHyG literal 0 HcmV?d00001 diff --git a/tests/testdata/test.png b/tests/testdata/test.png new file mode 100644 index 0000000000000000000000000000000000000000..ebf894d4706379dfac5e20875260617646988030 GIT binary patch literal 2746 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 S@hNCVA;tz*^ec5S5&r|5+Cww| literal 0 HcmV?d00001 diff --git a/tests/testdata/test.svg b/tests/testdata/test.svg new file mode 100644 index 0000000..fa24d8b --- /dev/null +++ b/tests/testdata/test.svg @@ -0,0 +1,7 @@ + + + + + testSVG + +