Skip to content

Commit

Permalink
Starting work on the Chromium HTML conversion route
Browse files Browse the repository at this point in the history
  • Loading branch information
stumpylog committed Oct 10, 2023
1 parent baede16 commit 5bf30eb
Show file tree
Hide file tree
Showing 10 changed files with 357 additions and 6 deletions.
10 changes: 4 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ style = [
]
fmt = [
"black {args:.}",
"ruff --fix {args:.}",
"ruff {args:.}",
"style",
]
all = [
Expand All @@ -120,6 +120,8 @@ line-length = 120
skip-string-normalization = true

[tool.ruff]
fix = true
output-format = "grouped"
target-version = "py38"
line-length = 120
extend-select = [
Expand Down Expand Up @@ -167,14 +169,10 @@ ignore = [
# Ignore complexity
"C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915",
]
unfixable = [
# Don't touch unused imports
"F401",
]

[tool.ruff.isort]
force-single-line = true
known-first-party = ["gotenberg-client"]
known-first-party = ["gotenberg_client"]

[tool.ruff.flake8-tidy-imports]
ban-relative-imports = "all"
Expand Down
51 changes: 51 additions & 0 deletions src/gotenberg_client/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import logging
from types import TracebackType
from typing import Dict
from typing import Optional
from typing import Type

from httpx import Client


class GotenbergClient:
def __init__(
self,
*,
gotenerg_url: str,
timeout: float = 30.0,
log_level: int = logging.ERROR,
compress: bool = False,
http2: bool = True,
):
# Configure the client
self._client = Client(base_url=gotenerg_url, timeout=timeout, http2=http2)

# Set the log level
logging.getLogger("httpx").setLevel(log_level)
logging.getLogger("httpcore").setLevel(log_level)

# Only JSON responses supported
self._client.headers.update({"Accept": "application/json"})

if compress:
self._client.headers.update({"Accept-Encoding": "gzip,br"})

# Add the resources
# TODO

def add_headers(self, header: Dict[str, str]) -> None: # pragma: no cover
"""
Updates the httpx Client headers with the given values
"""
self._client.headers.update(header)

def __enter__(self) -> "GotenbergClient":
return self

def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> None:
self._client.close()
3 changes: 3 additions & 0 deletions src/gotenberg_client/convert/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2023-present Trenton H <797416+stumpylog@users.noreply.github.com>
#
# SPDX-License-Identifier: MPL-2.0
229 changes: 229 additions & 0 deletions src/gotenberg_client/convert/chromium.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import dataclasses
import enum
import json
from pathlib import Path
from typing import Final
from typing import Optional
from urllib.parse import quote

from httpx import Client

from gotenberg_client.convert.common import PageOrientationOptions
from gotenberg_client.convert.common import PageRangeType
from gotenberg_client.convert.common import PdfAFormatOptions
from gotenberg_client.convert.common import optional_to_form


@dataclasses.dataclass
class PageSize:
width: float | int | None = None
height: float | int | None = None

def to_form(self) -> dict[str, str]:
data = optional_to_form(self.width, "paperWidth")
data.update(optional_to_form(self.height, "paperHeight"))
return data


# Define common paper sizes as shortcuts
A0: Final = PageSize(width=33.1, height=46.8)
A1: Final = PageSize(width=23.4, height=33.1)
A2: Final = PageSize(width=16.54, height=23.4)
A3: Final = PageSize(width=11.7, height=16.54)
A4: Final = PageSize(width=8.5, height=11)
A5: Final = PageSize(width=5.83, height=8.27)
A6: Final = PageSize(width=4.13, height=5.83)
Letter = A4
Legal: Final = PageSize(width=8.5, height=14)
Tabloid: Final = PageSize(width=11, height=17)
Ledge: Final = PageSize(width=17, height=11)


@dataclasses.dataclass
class Margin:
top: float | int | None = None
bottom: float | int | None = None
left: float | int | None = None
right: float | int | None = None

def to_form(self) -> dict[str, str]:
data = optional_to_form(self.top, "marginTop")
data.update(optional_to_form(self.bottom, "marginBottom"))
data.update(optional_to_form(self.left, "marginLeft"))
data.update(optional_to_form(self.right, "marginRight"))
return data


Gotenberg_Default_Margines: Final = Margin(0.39, 0.39, 0.39, 0.39)
Word_Default_Margins: Final = Margin(top=1.0, bottom=1.0, left=1.0, right=1.0)
Word_Narrow_Margins: Final = Margin(top=0.5, bottom=0.5, left=0.5, right=0.5)


@dataclasses.dataclass
class RenderControl:
delay: int | float | None = None
expression: str | None = None

def to_form(self) -> dict[str, str]:
data = optional_to_form(self.delay, "waitDelay")
data.update(optional_to_form(self.expression, "waitForExpression"))
return data


@dataclasses.dataclass
class HttpOptions:
user_agent: str | None = None
headers: dict[str, str] | None = None

def to_form(self) -> dict[str, str]:
data = optional_to_form(self.user_agent, "userAgent")
if self.headers is not None:
json_str = json.dumps(self.headers)
# TODO: Need to check this
data.update({"extraHttpHeaders": quote(json_str)})
return data


@enum.unique
class EmulatedMediaTypeOptions(str, enum.Enum):
Print = "print"
Screen = "screen"

def to_form(self) -> dict[str, str]:
return {"emulatedMediaType": self.value}


class ChromiumRoutes:
_URL_CONVERT_ENDPOINT = "/forms/chromium/convert/url"
_HTML_CONVERT_ENDPOINT = "/forms/chromium/convert/html"
_MARKDOWN_CONVERT_ENDPOINT = "/forms/chromium/convert/markdown"

def __init__(self, client: Client) -> None:
self._client = client

def convert_url(
self,
url: str,
*,
page_size: Optional[PageSize] = None,
margins: Optional[Margin] = None,
prefer_css_page_size: Optional[bool] = None,
print_background: Optional[bool] = None,
omit_background: Optional[bool] = None,
orientation: Optional[PageOrientationOptions] = None,
scale: Optional[int | float] = None,
page_ranges: Optional[PageRangeType] = None,
header: Optional[Path] = None,
footer: Optional[Path] = None,
render_control: Optional[RenderControl] = None,
media_type_emulation: Optional[EmulatedMediaTypeOptions] = None,
http_control: Optional[HttpOptions] = None,
fail_on_console_exceptions: Optional[bool] = None,
pdf_a_output: Optional[PdfAFormatOptions] = None,
) -> None:
pass

def convert_html(
self,
index_file: Path,
*,
additional_files: Optional[list[Path]] = None,
page_size: Optional[PageSize] = None,
margins: Optional[Margin] = None,
prefer_css_page_size: Optional[bool] = None,
print_background: Optional[bool] = None,
omit_background: Optional[bool] = None,
orientation: Optional[PageOrientationOptions] = None,
scale: Optional[int | float] = None,
page_ranges: Optional[PageRangeType] = None,
header: Optional[Path] = None,
footer: Optional[Path] = None,
render_control: Optional[RenderControl] = None,
media_type_emulation: Optional[EmulatedMediaTypeOptions] = None,
http_control: Optional[HttpOptions] = None,
fail_on_console_exceptions: Optional[bool] = None,
pdf_a_output: Optional[PdfAFormatOptions] = None,
) -> None:
self._build_common_options_form_data(
page_size=page_size,
margins=margins,
prefer_css_page_size=prefer_css_page_size,
print_background=print_background,
omit_background=omit_background,
orientation=orientation,
scale=scale,
page_ranges=page_ranges,
header=header,
footer=footer,
render_control=render_control,
media_type_emulation=media_type_emulation,
http_control=http_control,
fail_on_console_exceptions=fail_on_console_exceptions,
pdf_a_output=pdf_a_output,
)

def convert_markdown(
self,
index_file: Path,
markdown_files: list[Path],
*,
additional_files: Optional[list[Path]] = None,
page_size: Optional[PageSize] = None,
margins: Optional[Margin] = None,
prefer_css_page_size: Optional[bool] = None,
print_background: Optional[bool] = None,
omit_background: Optional[bool] = None,
orientation: Optional[PageOrientationOptions] = None,
scale: Optional[int | float] = None,
page_ranges: Optional[PageRangeType] = None,
header: Optional[Path] = None,
footer: Optional[Path] = None,
render_control: Optional[RenderControl] = None,
media_type_emulation: Optional[EmulatedMediaTypeOptions] = None,
http_control: Optional[HttpOptions] = None,
fail_on_console_exceptions: Optional[bool] = None,
pdf_a_output: Optional[PdfAFormatOptions] = None,
) -> None:
pass

@staticmethod
def _build_common_options_form_data(
*,
page_size: Optional[PageSize] = None,
margins: Optional[Margin] = None,
prefer_css_page_size: Optional[bool] = None,
print_background: Optional[bool] = None,
omit_background: Optional[bool] = None,
orientation: Optional[PageOrientationOptions] = None,
scale: Optional[int | float] = None,
page_ranges: Optional[PageRangeType] = None,
header: Optional[Path] = None,
footer: Optional[Path] = None,
render_control: Optional[RenderControl] = None,
media_type_emulation: Optional[EmulatedMediaTypeOptions] = None,
http_control: Optional[HttpOptions] = None,
fail_on_console_exceptions: Optional[bool] = None,
pdf_a_output: Optional[PdfAFormatOptions] = None,
) -> dict[str, str]:
data = {}
if page_size is not None:
data.update(page_size.to_form())
if margins is not None:
data.update(margins.to_form())
data.update(optional_to_form(prefer_css_page_size, "preferCssPageSize"))
data.update(optional_to_form(print_background, "printBackground"))
data.update(optional_to_form(omit_background, "omitBackground"))
if orientation is not None:
data.update(orientation.to_form())
data.update(optional_to_form(scale, "scale"))
# TODO page ranges
# TODO header & footer
if render_control is not None:
data.update(render_control.to_form())
if media_type_emulation is not None:
data.update(media_type_emulation.to_form())
if http_control is not None:
data.update(http_control.to_form())
data.update(optional_to_form(fail_on_console_exceptions, "failOnConsoleExceptions"))
if pdf_a_output is not None:
data.update(pdf_a_output.to_form())
44 changes: 44 additions & 0 deletions src/gotenberg_client/convert/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import enum
from functools import lru_cache
from typing import Optional


@enum.unique
class PageOrientationOptions(enum.Enum):
Landscape = enum.auto()
Potrait = enum.auto()

def to_form(self) -> dict[str, str]:
if self.value == PageOrientationOptions.Landscape:
return {"landscape": "true"}
else:
return {"landscape": "false"}


@enum.unique
class PdfAFormatOptions(enum.Enum):
A1a = enum.auto()
A2a = enum.auto()
A3b = enum.auto()


PageRangeType = list[list[int]] | None


@lru_cache
def optional_to_form(value: Optional[bool | int | float | str], name: str) -> dict[str, str]:
"""
Quick helper to convert an optional type into a form data field
with the given name or no changes if the value is None
"""
if value is None:
return {}
else:
return {name: str(value).lower()}


@lru_cache
def optional_page_ranges_to_form(value: Optional[PageRangeType], name: str) -> dict[str, str]:
"""
Converts a list of lists of pages into the formatted strings Gotenberg expects
"""
26 changes: 26 additions & 0 deletions src/gotenberg_client/convert/libre_office.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import dataclasses

from gotenberg_client.convert.common import PageOrientationOptions
from gotenberg_client.convert.common import PageRangeType
from gotenberg_client.convert.common import PdfAFormatOptions


@dataclasses.dataclass
class PageProperties:
"""
Defines possible page properties for the Libre Office routes, along
with the default values.
Documentation:
- https://gotenberg.dev/docs/routes#page-properties-libreoffice
"""

orientation: PageOrientationOptions = PageOrientationOptions.Potrait
pages: PageRangeType = None


@dataclasses.dataclass
class LibreOfficeRouteOptions:
page_properties: PageProperties | None = None
merge: bool | None = None
pdf_a_format: PdfAFormatOptions | None = None
Empty file.
Empty file added src/gotenberg_client/health.py
Empty file.
Empty file added src/gotenberg_client/merge.py
Empty file.
Empty file added src/gotenberg_client/metrics.py
Empty file.

0 comments on commit 5bf30eb

Please sign in to comment.