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 34fbe57
Show file tree
Hide file tree
Showing 10 changed files with 304 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 <[email protected]>
#
# SPDX-License-Identifier: MPL-2.0
183 changes: 183 additions & 0 deletions src/gotenberg_client/convert/chromium.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
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:
data = {}
data.update(page_size.to_form())
data.update(margins.to_form())
data.update(optional_to_form(prefer_css_page_size))
data.update(optional_to_form(print_background))
data.update(optional_to_form(omit_background))
if orientation is not None:
data.update(orientation.to_form())
data.update(optional_to_form(scale))
# TODO page ranges
# TODO header & footer
data.update(render_control.to_form())
data.update(optional_to_form(omit_background))

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
37 changes: 37 additions & 0 deletions src/gotenberg_client/convert/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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()}
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 34fbe57

Please sign in to comment.