Skip to content
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ plugins:
#
#render_js: true
#headless_chrome_path: headless-chromium
#mermaid_args: '-b transparent -t dark --scale 4 --quiet'
#mermaid_img_scale_reduction: 4
#
#output_path: any-place/document.pdf
#enabled_if_env: ENABLE_PDF_EXPORT
Expand Down Expand Up @@ -245,6 +247,17 @@ plugins:
> <ANY_SITE_URL(eg. 'https://google.com')>
> ```

* `mermaid_args`

Arguments to use when calling `mmdc` to generate mermaid diagrams

**default**: '-b transparent -t dark --scale 4 --quiet'

* `mermaid_img_scale_reduction`

Visual scale to reduce visual size of diagrams when using `--scale` greater than 1 in `mermaid_args`.
This allows higher resolution diagram renders at the native visual size.

* `relaxedjs_path`

Set the value to execute command of relaxed if you're using e.g. '[Mermaid](https://mermaid-js.github.io) diagrams and Headless Chrome is not working for you.
Expand Down
57 changes: 24 additions & 33 deletions mkdocs_with_pdf/drivers/headless_chrome.py
Original file line number Diff line number Diff line change
@@ -1,55 +1,47 @@
import os
import html as html_lib
from ..utils.mermaid_util import render_mermaid
from logging import Logger
from shutil import which
from subprocess import PIPE, Popen
import re
import pathlib


class HeadlessChromeDriver(object):
""" 'Headless Chrome' executor """

@classmethod
def setup(self, program_path: str, logger: Logger):
def setup(self,
program_path: str,
mermaid_args: str,
mermaid_img_scale_reduction: float,
logger: Logger):
if not which(program_path):
raise RuntimeError(
'No such `Headless Chrome` program or not executable'
+ f': "{program_path}".')
return self(program_path, logger)
return self(program_path,
mermaid_args,
mermaid_img_scale_reduction,
logger)

def __init__(self, program_path: str, logger: Logger):
def __init__(self, program_path: str,
mermaid_args: str,
mermaid_img_scale_reduction: float,
logger: Logger):
self._program_path = program_path
self.mermaid_args = mermaid_args
self.mermaid_img_scale_reduction = mermaid_img_scale_reduction
self._logger = logger

def render(self, html: str, temporary_directory: pathlib.Path) -> str:
try:
mermaid_regex = r'<pre><code class="language-mermaid">(.*?)</code></pre>'
mermaid_matches = re.findall(mermaid_regex, html, flags=re.DOTALL)
html = render_mermaid(
html,
temporary_directory,
self.mermaid_args,
self.mermaid_img_scale_reduction,
self._logger)

# Convert each Mermaid diagram to an image.
for i, mermaid_code in enumerate(mermaid_matches):
self._logger.info(f"Converting mermaid diagram {i}")

# Create a temporary file to hold the Mermaid code.
mermaid_file_path = temporary_directory / f"diagram_{i + 1}.mmd"
with open(mermaid_file_path, "wb") as mermaid_file:
mermaid_code_unescaped = html_lib.unescape(mermaid_code)
mermaid_file.write(mermaid_code_unescaped.encode("utf-8"))

# Create a filename for the image.
image_file_path = temporary_directory / f"diagram_{i + 1}.png"

# Convert the Mermaid diagram to an image using mmdc.
command = f"mmdc -i {mermaid_file_path} -o {image_file_path} -b transparent -t dark --scale 4 --quiet"

os.system(command)

# Replace the Mermaid code with the image in the HTML string.
image_html = f'<img src="file://{image_file_path}" alt="Mermaid diagram {i+1}">'
html = html.replace(f'<pre><code class="language-mermaid">{mermaid_code}</code></pre>', image_html)

self._logger.info(f"Post mermaid translation: {html}")
self._logger.debug(f"Post mermaid translation: {html}")
with open(temporary_directory / "post_mermaid_translation.html", "wb") as temp:
temp.write(html.encode('utf-8'))

Expand All @@ -59,14 +51,13 @@ def render(self, html: str, temporary_directory: pathlib.Path) -> str:
'--no-sandbox',
'--headless',
'--disable-gpu',
'--disable-web-security',
'-–allow-file-access-from-files',
'--run-all-compositor-stages-before-draw',
'--virtual-time-budget=10000',
'--dump-dom',
temp.name], stdout=PIPE) as chrome:
chrome_output = chrome.stdout.read().decode('utf-8')
self._logger.info(f"Post chrome translation: {chrome_output}")
self._logger.debug(f"Post chrome translation: {chrome_output}")
return chrome_output

except Exception as e:
Expand Down
35 changes: 31 additions & 4 deletions mkdocs_with_pdf/drivers/relaxedjs.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import os
from ..utils.mermaid_util import render_mermaid
from logging import Logger
from shutil import which
from subprocess import PIPE, Popen
from tempfile import TemporaryDirectory
import pathlib


class RelaxedJSRenderer(object):

@classmethod
def setup(self, program_path: str, logger: Logger):
def setup(self,
program_path: str,
mermaid_args: str,
mermaid_img_scale_reduction: float,
logger: Logger):
if not program_path:
return None

Expand All @@ -17,16 +23,33 @@ def setup(self, program_path: str, logger: Logger):
'No such `ReLaXed` program or not executable'
+ f': "{program_path}".')

return self(program_path, logger)
return self(program_path,
mermaid_args,
mermaid_img_scale_reduction,
logger)

def __init__(self, program_path: str, logger: Logger):
def __init__(self, program_path: str,
mermaid_args: str,
mermaid_img_scale_reduction: float,
logger: Logger):
self._program_path = program_path
self.mermaid_args = mermaid_args
self.mermaid_img_scale_reduction = mermaid_img_scale_reduction
self._logger = logger

def write_pdf(self, html_string: str, output: str):
def write_pdf(self, html_string: str,
output: str,
temporary_directory: pathlib.Path):
self._logger.info(' Rendering with `ReLaXed JS`.')

with TemporaryDirectory() as work_dir:
html_string = render_mermaid(
html_string,
work_dir,
self.mermaid_args,
self.mermaid_img_scale_reduction,
self._logger)

entry_point = os.path.join(work_dir, 'pdf_print.html')
with open(entry_point, 'w+') as f:
f.write(html_string)
Expand All @@ -42,3 +65,7 @@ def write_pdf(self, html_string: str, output: str):
self._logger.info(f" {log}")
if proc.poll() is not None:
break
# workaround for '--build-once' not working
if log.find("Now idle and waiting for file changes") > -1:
proc.kill()
break
3 changes: 1 addition & 2 deletions mkdocs_with_pdf/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ def add_stylesheet(stylesheet: str):

if self._options.relaxed_js:
self._options.relaxed_js.write_pdf(
html_string, abs_pdf_path)
html_string, abs_pdf_path, temporary_directory)
else:
html = HTML(string=html_string)
render = html.render()
Expand Down Expand Up @@ -397,7 +397,6 @@ def _render_js(self, soup, temporary_directory: pathlib.Path):
body.append(tag)
for src in scripts:
body.append(soup.new_tag('script', src=f'file://{src}'))

return self._options.js_renderer.render(str(soup), temporary_directory)

def _scrap_scripts(self, soup):
Expand Down
12 changes: 10 additions & 2 deletions mkdocs_with_pdf/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ class Options(object):
('two_columns_level', config_options.Type(int, default=0)),

('render_js', config_options.Type(bool, default=False)),
('mermaid_args', config_options.Type(str, default="-b transparent -t dark --scale 4 --quiet")),
('mermaid_img_scale_reduction', config_options.Type((float, int), default=1)),
('headless_chrome_path',
config_options.Type(str, default='chromium-browser')),
('relaxedjs_path',
Expand Down Expand Up @@ -96,10 +98,16 @@ def __init__(self, local_config, config, logger: logging):
self.js_renderer = None
if local_config['render_js']:
self.js_renderer = HeadlessChromeDriver.setup(
local_config['headless_chrome_path'], logger)
local_config['headless_chrome_path'],
local_config['mermaid_args'],
local_config['mermaid_img_scale_reduction'],
logger)

self.relaxed_js = RelaxedJSRenderer.setup(
local_config['relaxedjs_path'], logger)
local_config['relaxedjs_path'],
local_config['mermaid_args'],
local_config['mermaid_img_scale_reduction'],
logger)

# Theming
self.theme_name = config['theme'].name
Expand Down
7 changes: 6 additions & 1 deletion mkdocs_with_pdf/utils/image_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ def fix_image_alignment(soup: PageElement, logger: Logger = None):
if img.has_attr('class') and 'twemoji' in img['class']:
continue

styles = _parse_style(getattr(img, 'style', ''))
if not (img.has_attr('align')
or img.has_attr('width')
or img.has_attr('height')):
continue

styles = _parse_style(img.get('style', ''))

logger.debug(f' | {img}')
if img.has_attr('align'):
Expand Down
63 changes: 63 additions & 0 deletions mkdocs_with_pdf/utils/mermaid_util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import os
import re
import html as html_lib
import pathlib
from logging import Logger
from typing import Union


def render_mermaid(html: str,
temporary_directory: Union[str, pathlib.Path],
mermaid_args: str,
mermaid_img_scale_reduction: float,
logger: Logger):

if (isinstance(temporary_directory, str)):
temporary_directory = pathlib.Path(temporary_directory)

mermaid_regex = re.compile(r'<(\w*?[^>]*)(><[^>]*?|[^>]*?)class="[^>\"]*(language-)?mermaid[^>\"]*">(<[^>]*?>)?(?P<code>.*?)(<\/[^>]*?>)?<\/\1>', flags=re.DOTALL)
mermaid_matches = mermaid_regex.finditer(html)

i = 0
# Convert each Mermaid diagram to an image.
for mermaid_block in mermaid_matches:
i += 1
logger.info(f"Converting mermaid diagram {i}")
mermaid_code = mermaid_block.group("code")

# Create a temporary file to hold the Mermaid code.
mermaid_file_path = temporary_directory / f"diagram_{i + 1}.mmd"
with open(mermaid_file_path, "wb") as mermaid_file:
mermaid_code_unescaped = html_lib.unescape(mermaid_code)
mermaid_file.write(mermaid_code_unescaped.encode("utf-8"))

# Create a filename for the image.
image_file_path = temporary_directory / f"diagram_{i}.png"

# Convert the Mermaid diagram to an image using mmdc.
command = f"mmdc -i {mermaid_file_path} -o {image_file_path} {mermaid_args}"

# suppress sub-process chatter when using '--quiet'
if mermaid_args.find('--quiet') > -1 or mermaid_args.find(' -q ') > -1 or mermaid_args.endswith(' -q'):
command += " >/dev/null 2>&1"

os.system(command)

if not os.path.exists(image_file_path):
logger.warning(f"Error: Failed to generate mermaid diagram {i}")
else:
from PIL import Image

with Image.open(image_file_path) as im:
# Replace the Mermaid code with the image in the HTML string.
image_html = f'<img src="file://{image_file_path}" alt="Mermaid diagram {i}">'

if mermaid_img_scale_reduction != 1:
height = im.height // mermaid_img_scale_reduction
width = im.width // mermaid_img_scale_reduction
image_html = image_html.replace('">', f'" style="max-width:{width}px; max-height:{height}px;">')

html = html.replace(mermaid_block.group(0),
mermaid_block.group(0).replace(mermaid_code, image_html))

return html