Skip to content

Commit

Permalink
Added support for customizing ZIM logo
Browse files Browse the repository at this point in the history
  • Loading branch information
josephlewis42 committed Nov 3, 2024
1 parent 1fc6fc7 commit 2a84b94
Show file tree
Hide file tree
Showing 9 changed files with 127 additions and 3 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Support for setting a custom icon for produced ZIM files. (#32)

### Changed

- Page navigation is now dynamically rendered reducing file sizes. (#24, #31)
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
1 change: 1 addition & 0 deletions src/devdocs2zim/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions src/devdocs2zim/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from devdocs2zim.client import DevdocsClient
from devdocs2zim.constants import (
DEFAULT_LOGO_PATH,
DEVDOCS_DOCUMENTS_URL,
DEVDOCS_FRONTEND_URL,
NAME,
Expand All @@ -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.absolute()),
)


Expand Down
62 changes: 60 additions & 2 deletions src/devdocs2zim/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,29 @@
import datetime
import os
import re
import tempfile
from collections import defaultdict
from pathlib import Path

from bs4 import BeautifulSoup
from jinja2 import Environment, FileSystemLoader, select_autoescape
from PIL import UnidentifiedImageError
from pydantic import BaseModel
from zimscraperlib.constants import ( # pyright: ignore[reportMissingTypeStubs]
MAXIMUM_DESCRIPTION_METADATA_LENGTH,
MAXIMUM_LONG_DESCRIPTION_METADATA_LENGTH,
RECOMMENDED_MAX_TITLE_LENGTH,
)
from zimscraperlib.image.conversion import ( # pyright: ignore[reportMissingTypeStubs]
convert_image,
convert_svg2png,
)
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,
Expand All @@ -29,6 +41,7 @@
DevdocsMetadata,
)
from devdocs2zim.constants import (
DEFAULT_LOGO_PATH,
LANGUAGE_ISO_639_3,
LICENSE_FILE,
NAME,
Expand Down Expand Up @@ -71,6 +84,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"):
Expand Down Expand Up @@ -140,6 +155,15 @@ def add_flags(parser: argparse.ArgumentParser, defaults: "ZimConfig"):
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."""
Expand Down Expand Up @@ -195,6 +219,7 @@ def check_length(string: str, field_name: str, length: int) -> str:
else None
),
tags=fmt(self.tags),
logo_format=fmt(self.logo_format),
)


Expand Down Expand Up @@ -339,7 +364,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")

Expand Down Expand Up @@ -456,6 +480,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,
Expand All @@ -469,7 +494,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.
Expand All @@ -491,6 +516,39 @@ 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: Path | None = handle_user_provided_file(source=user_logo_path)
if full_logo_path is None:
logger.warning(" Fetching logo failed, using fallback.")
full_logo_path = DEFAULT_LOGO_PATH

# convert to PNG
png_logo_path = Path(
tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
)
try:
convert_image(full_logo_path, png_logo_path, fmt="PNG")
except UnidentifiedImageError:
convert_svg2png(full_logo_path, png_logo_path, 48, 48)
# SVG conversion generates a PNG in the correct size
# so immediately return it.
return png_logo_path.read_bytes()

# resize to 48x48
resized_logo_path = Path(
tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
)
resize_image(png_logo_path, 48, 48, resized_logo_path, allow_upscaling=True)

return resized_logo_path.read_bytes()

@staticmethod
def page_titles(pages: list[DevdocsIndexEntry]) -> dict[str, str]:
"""Returns a map between page paths in the DB and their "best" title.
Expand Down
52 changes: 51 additions & 1 deletion tests/test_generator.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import argparse
import io
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import TestCase
from unittest.mock import create_autospec
from unittest.mock import create_autospec, patch

from PIL.Image import open as pilopen

from devdocs2zim.client import (
DevdocsClient,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -66,6 +70,8 @@ def test_flag_parsing_overrides(self):
"long-description-format",
"--tags",
"tag1;tag2",
"--logo-format",
"logo-format",
]
)
)
Expand All @@ -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,
)
Expand All @@ -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"})
Expand All @@ -115,6 +123,7 @@ def test_format_only_allowed(self):
description_format="replaced",
long_description_format="replaced",
tags="replaced",
logo_format="replaced",
),
got,
)
Expand Down Expand Up @@ -426,3 +435,44 @@ 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_fallback(self):
with patch(
"devdocs2zim.generator.handle_user_provided_file"
) as mock_handle_user_provided_file:
mock_handle_user_provided_file.return_value = None

got = Generator.fetch_logo_bytes("does_not_exist")

mock_handle_user_provided_file.assert_called_with(source="does_not_exist")
self.assertIsNotNone(got)
Binary file added tests/testdata/test.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/testdata/test.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions tests/testdata/test.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 2a84b94

Please sign in to comment.