Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mermaid-cli arguments option and flexible HTML detection #14

Open
wants to merge 10 commits into
base: mermaid-cli
Choose a base branch
from
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