Skip to content

Modules

This module defines a base class for jinja2 extensions that generate images.

GenImageExtension

Bases: Extension

Source code in jinja2_mermaid_extension/base.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
class GenImageExtension(Extension):
    tags: set[str] = {"yaml"}  # noqa: RUF012
    input_root_key: str | None = None
    output_root_key: str | None = None

    def __init__(self, environment: Environment):
        super().__init__(environment)

    def parse(self, parser: Parser) -> nodes.Node:
        """
        The logic to parse the jinja2 block as yaml.
        """
        line = next(parser.stream).lineno
        block = parser.parse_statements((f"name:end{next(iter(self.tags))}",), drop_needle=True)
        kwargs = yaml.safe_load(cast(nodes.TemplateData, cast(nodes.Output, block[0]).nodes[0]).data)
        callback = self.call_method("_render", [nodes.Const(json.dumps(kwargs))])
        return nodes.CallBlock(callback, [], [], block).set_lineno(line)

    @staticmethod
    def modify(**kwargs: Any) -> Generator[tuple[str, Any], None, None]:
        """
        Intercept and modify the keyword arguments before passing them to the callback function.
        """
        yield from kwargs.items()

    def callback(self, inp: Path | str, out: Path, inp_root: Path, out_root: Path, **kwargs: Any) -> None:
        """
        The function to call to generate an image.
        """
        raise NotImplementedError

    @property
    def _valid_keys(self) -> Generator[str]:
        yield from ()

    @pass_context
    def _render(self, context: Context, kwargs_json: str, caller: Macro) -> str:
        kwargs = dict(self.modify(**json.loads(kwargs_json)))
        valid_keys = set(inspect.signature(self._gen_markdown_lines).parameters) | set(self._valid_keys)
        valid_keys = valid_keys - {"context", "output_name_salt", "out"}
        unknown_keys = set(kwargs.keys()) - valid_keys
        if any(unknown_keys):
            raise TypeError(f"callback got unexpected keyword arguments: {', '.join(unknown_keys)}")

        return "\n".join(self._gen_markdown_lines(context, output_name_salt=kwargs_json, **kwargs))

    def _gen_markdown_lines(  # noqa: C901
        self,
        context: Context,
        inp: Path | str,
        ext: str = ".png",
        name: str | None = None,
        mode: str | Mode = Mode.OUT,
        width: int | str | None = None,
        height: int | str | None = None,
        align: str = "center",
        caption: str | None = None,
        full_path: bool = False,
        just_name: bool = False,
        use_cached: bool = True,
        parallel: bool = False,
        output_name_salt: str = "...",
        **kwargs: Any,
    ) -> Generator[str, None, None]:
        """
        Run callback and yield a series of markdown commands to include it .
        """
        if isinstance(mode, str):
            mode = LOOKUP_MODE[mode.strip().lower()]

        out_root = self._get_output_root(context)
        if name is None:
            name = str(uuid5(namespace, str(inp) + output_name_salt))

        out = out_root.joinpath(name).with_suffix("." + ext.lower().lstrip("."))

        if not out.exists() or not use_cached:
            if out in runner():
                logger.warning("ignore: %s", out)
            else:
                if parallel:
                    logger.warning("submit: %s", out)
                    runner().run(
                        key=out,
                        fn=self.callback,
                        inp=inp,
                        out=out,
                        inp_root=self._get_input_root(context),
                        out_root=out_root,
                        **kwargs,
                    )
                else:
                    logger.warning("create: %s", out)
                    self.callback(
                        inp=inp,
                        out=out,
                        inp_root=self._get_input_root(context),
                        out_root=self._get_output_root(context),
                        **kwargs,
                    )
                    runner().running.add(out)
        else:
            logger.warning("cached: %s", out)

        if just_name:
            stem = out.name
        elif not full_path:
            stem = str(out.relative_to(Path(out_root)))
        else:
            stem = str(out)

        if mode == Mode.OUT:
            yield stem
        elif mode == Mode.MD:
            yield from self._render_md(out, stem, caption)
        elif mode == Mode.RST:
            yield from self._render_rst(stem, caption)
        elif mode == Mode.MYST:
            yield from self._render_myst(stem, align, caption, width, height)
        else:
            raise ValueError(f"Unknown mode: {mode}")

    @classmethod
    def _get_input_root(cls, context: Context) -> Path:
        if cls.input_root_key is None:
            return Path.cwd()

        if (root := context.parent.get(str(cls.input_root_key))) is None:
            return Path.cwd()

        return Path(cast(Path, root))

    @classmethod
    def _get_output_root(cls, context: Context) -> Path:
        if cls.output_root_key is None:
            return Path.cwd()

        if (root := context.parent.get(str(cls.output_root_key))) is None:
            return Path.cwd()

        return Path(cast(Path, root))

    @staticmethod
    def _render_md(out: Path, stem: str, caption: str | None) -> Generator[str, None, None]:
        if caption is not None:
            caption = caption.rstrip()
            yield f"![{caption}]({stem})"
        else:
            yield f"![{out.name}]({stem})"

    @staticmethod
    def _render_rst(stem: str, caption: str | None) -> Generator[str, None, None]:
        if caption is not None:
            yield f".. image:: {stem}\n   :alt: {caption.rstrip()}"
        else:
            yield f".. image:: {stem}"

    @staticmethod
    def _render_myst(
        stem: str, align: str, caption: str | None, width: int | str | None, height: int | str | None
    ) -> Generator[str, None, None]:
        if caption is not None:
            yield f":::{{figure}} {stem}"
        else:
            yield f":::{{image}} {stem}"
        if width is not None:
            yield f":width: {width}"
        if height is not None:
            yield f":height: {height}"
        if align is not None:
            yield f":align: {align}"
        if caption is not None:
            yield f"\n{caption.rstrip()}"
        yield r":::"

callback(inp, out, inp_root, out_root, **kwargs)

The function to call to generate an image.

Source code in jinja2_mermaid_extension/base.py
115
116
117
118
119
def callback(self, inp: Path | str, out: Path, inp_root: Path, out_root: Path, **kwargs: Any) -> None:
    """
    The function to call to generate an image.
    """
    raise NotImplementedError

modify(**kwargs) staticmethod

Intercept and modify the keyword arguments before passing them to the callback function.

Source code in jinja2_mermaid_extension/base.py
108
109
110
111
112
113
@staticmethod
def modify(**kwargs: Any) -> Generator[tuple[str, Any], None, None]:
    """
    Intercept and modify the keyword arguments before passing them to the callback function.
    """
    yield from kwargs.items()

parse(parser)

The logic to parse the jinja2 block as yaml.

Source code in jinja2_mermaid_extension/base.py
 98
 99
100
101
102
103
104
105
106
def parse(self, parser: Parser) -> nodes.Node:
    """
    The logic to parse the jinja2 block as yaml.
    """
    line = next(parser.stream).lineno
    block = parser.parse_statements((f"name:end{next(iter(self.tags))}",), drop_needle=True)
    kwargs = yaml.safe_load(cast(nodes.TemplateData, cast(nodes.Output, block[0]).nodes[0]).data)
    callback = self.call_method("_render", [nodes.Const(json.dumps(kwargs))])
    return nodes.CallBlock(callback, [], [], block).set_lineno(line)

This module defines a callback function for generating mermaid diagrams.

MermaidCallback

Bases: RunCommandInTempDir

A callback function for generating mermaid diagrams.

Source code in jinja2_mermaid_extension/callback.py
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
class MermaidCallback(RunCommandInTempDir):
    """
    A callback function for generating mermaid diagrams.
    """

    #: The extension for raw input files.
    RAW_INPUT_EXT: ClassVar[str] = ".mmd"
    #: The valid extensions for output files.
    VALID_OUT_EXT: ClassVar[frozenset[str]] = frozenset((".svg", ".png", ".pdf"))

    def command(self, *, tmp_inp: Path, tmp_out: Path, tmp_root: Path, **kwargs: Any) -> Generator[str, None, None]:
        """
        Generate the command to run.

        Args:
            tmp_inp: The input file, located in the temporary directory.
            tmp_out: The output file, located in the temporary directory.
            tmp_root: The current temporary directory.
            kwargs: Additional keyword arguments.

        Yields:
            str: The command strings that were generated.
        """
        opts = MermaidOptions(**kwargs)

        if opts.use_local_mmdc_instead:
            yield "mmdc"
        else:
            yield "docker"
            yield "run"
            yield "--rm"
            yield "-u"
            yield f"{os.getuid()}"
            yield "-v"
            yield f"{tmp_root}:{opts.mermaid_volume_mount}"
            yield opts.mermaid_docker_image

        yield "-t"
        yield opts.theme
        yield "-b"
        yield opts.background
        yield "-s"
        yield str(opts.scale)
        yield "-w"
        yield str(opts.render_width)
        yield from (() if opts.render_height is None else ("-H", str(opts.render_height)))
        yield "-i"
        yield tmp_inp.name
        yield "-o"
        yield tmp_out.name

command(*, tmp_inp, tmp_out, tmp_root, **kwargs)

Generate the command to run.

Parameters:

Name Type Description Default
tmp_inp Path

The input file, located in the temporary directory.

required
tmp_out Path

The output file, located in the temporary directory.

required
tmp_root Path

The current temporary directory.

required
kwargs Any

Additional keyword arguments.

{}

Yields:

Name Type Description
str str

The command strings that were generated.

Source code in jinja2_mermaid_extension/callback.py
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
def command(self, *, tmp_inp: Path, tmp_out: Path, tmp_root: Path, **kwargs: Any) -> Generator[str, None, None]:
    """
    Generate the command to run.

    Args:
        tmp_inp: The input file, located in the temporary directory.
        tmp_out: The output file, located in the temporary directory.
        tmp_root: The current temporary directory.
        kwargs: Additional keyword arguments.

    Yields:
        str: The command strings that were generated.
    """
    opts = MermaidOptions(**kwargs)

    if opts.use_local_mmdc_instead:
        yield "mmdc"
    else:
        yield "docker"
        yield "run"
        yield "--rm"
        yield "-u"
        yield f"{os.getuid()}"
        yield "-v"
        yield f"{tmp_root}:{opts.mermaid_volume_mount}"
        yield opts.mermaid_docker_image

    yield "-t"
    yield opts.theme
    yield "-b"
    yield opts.background
    yield "-s"
    yield str(opts.scale)
    yield "-w"
    yield str(opts.render_width)
    yield from (() if opts.render_height is None else ("-H", str(opts.render_height)))
    yield "-i"
    yield tmp_inp.name
    yield "-o"
    yield tmp_out.name

MermaidOptions dataclass

Bases: Options

Specific options for the mermaid callback function.

Source code in jinja2_mermaid_extension/callback.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
@dataclass
class MermaidOptions(Options):
    """
    Specific options for the mermaid callback function.
    """

    #: The theme to use for the diagram.
    theme: str = "default"
    #: A scaling factor for the diagram.
    scale: int = 3
    #: The width of the diagram in pixels.
    render_width: int = 800
    #: The height of the diagram in pixels.
    render_height: int | None = None
    #: The background color of the generated diagram.
    background: str = "white"
    #: The docker image containing the mermaid-cli tool.
    mermaid_docker_image: str = "minlag/mermaid-cli"
    #: The directory in the docker container to mount the temporary directory to.
    mermaid_volume_mount: str = "/data"
    #: Whether to use the docker image or a locally installed mermaid-cli tool named mmdc.
    use_local_mmdc_instead: bool = False

Options dataclass

Specific options for a callback function.

Source code in jinja2_mermaid_extension/callback.py
46
47
48
49
50
@dataclass
class Options:
    """
    Specific options for a callback function.
    """

RunCommandInTempDir

A wrapper to run a command in a temporary directory.

Source code in jinja2_mermaid_extension/callback.py
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
class RunCommandInTempDir:
    """
    A wrapper to run a command in a temporary directory.
    """

    #: The extension for raw input files.
    RAW_INPUT_EXT: ClassVar[str] = ""
    #: The valid extensions for output files.
    VALID_OUT_EXT: ClassVar[frozenset[str]] = frozenset(())

    @staticmethod
    def preprocess(inp: str, **kwargs: Any) -> str:
        """
        Preprocess the input string.

        Args:
            inp: The input string.

        Returns:
            str: The preprocessed input string.
        """
        return inp

    def command(self, *, tmp_inp: Path, tmp_out: Path, tmp_root: Path, **kwargs: Any) -> Generator[str, None, None]:
        """
        Generate the command to run.

        Args:
            tmp_inp: The input file, located in the temporary directory.
            tmp_out: The output file, located in the temporary directory.
            tmp_root: The current temporary directory.
            kwargs: Additional keyword arguments.

        Yields:
            str: The command strings that were generated.
        """
        raise NotImplementedError

    @staticmethod
    def finalize(*, out: Path, tmp_inp: Path, tmp_out: Path, tmp_root: Path, **kwargs: Any) -> None:
        """
        Finalize the output file.

        Args:
            out: The output file.
            tmp_inp: The input file, located in the temporary directory.
            tmp_out: The output file, located in the temporary directory.
            tmp_root: The current temporary directory.
            **kwargs: Additional keyword arguments.

        Returns:
            The finalized output file.
        """
        if not tmp_out.exists():
            raise FileNotFoundError(tmp_out)

        shutil.copy(tmp_out, out)

    def __call__(
        self, *, inp: Path | str, out: Path, temp_dir: Path | None = None, delete_temp_dir: bool = True, **kwargs: Any
    ) -> None:
        """
        Run the command in a temporary directory.

        Args:
            inp: The input file or a raw input string.
            out: The output file.
            temp_dir: A temporary directory to use for intermediate files.
            delete_temp_dir: Whether to delete the temporary directory after execution.
            **kwargs: Additional keyword arguments.
        """
        out = Path(out)

        with handle_temp_root(temp_dir, delete_temp_dir) as tmp_root:
            if isinstance(inp, str):
                tmp_inp = tmp_root / out.with_suffix(self.RAW_INPUT_EXT).name
                with tmp_inp.open("w") as stream:
                    stream.write(self.preprocess(inp))
            else:
                if not inp.exists():
                    raise FileNotFoundError(f"input file does not exist!: {inp}")

                tmp_inp = tmp_root / inp.name
                with tmp_inp.open("w") as stream:
                    stream.write(self.preprocess(inp.read_text()))

            if not out.parent.exists():
                raise FileNotFoundError(f"output directory does not exist!: {out.parent}")

            if out.is_dir():
                raise IsADirectoryError(out)

            tmp_out = tmp_root / out.name
            if tmp_out.exists():
                raise FileExistsError(tmp_out)

            if tmp_out.suffix.lower() not in self.VALID_OUT_EXT:
                raise ValueError(
                    f"Expected output file to have a {', '.join(self.VALID_OUT_EXT)} extension, got {tmp_out.suffix}"
                )

            if tmp_inp.suffix.lower() not in {self.RAW_INPUT_EXT}:
                raise ValueError(f"Expected input file to have a .mmd extension, got {tmp_inp.suffix}")

            run(self.command(tmp_inp=tmp_inp, tmp_out=tmp_out, tmp_root=tmp_root, **kwargs), check=True)
            self.finalize(out=out, tmp_inp=tmp_inp, tmp_out=tmp_out, tmp_root=tmp_root, **kwargs)

__call__(*, inp, out, temp_dir=None, delete_temp_dir=True, **kwargs)

Run the command in a temporary directory.

Parameters:

Name Type Description Default
inp Path | str

The input file or a raw input string.

required
out Path

The output file.

required
temp_dir Path | None

A temporary directory to use for intermediate files.

None
delete_temp_dir bool

Whether to delete the temporary directory after execution.

True
**kwargs Any

Additional keyword arguments.

{}
Source code in jinja2_mermaid_extension/callback.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
def __call__(
    self, *, inp: Path | str, out: Path, temp_dir: Path | None = None, delete_temp_dir: bool = True, **kwargs: Any
) -> None:
    """
    Run the command in a temporary directory.

    Args:
        inp: The input file or a raw input string.
        out: The output file.
        temp_dir: A temporary directory to use for intermediate files.
        delete_temp_dir: Whether to delete the temporary directory after execution.
        **kwargs: Additional keyword arguments.
    """
    out = Path(out)

    with handle_temp_root(temp_dir, delete_temp_dir) as tmp_root:
        if isinstance(inp, str):
            tmp_inp = tmp_root / out.with_suffix(self.RAW_INPUT_EXT).name
            with tmp_inp.open("w") as stream:
                stream.write(self.preprocess(inp))
        else:
            if not inp.exists():
                raise FileNotFoundError(f"input file does not exist!: {inp}")

            tmp_inp = tmp_root / inp.name
            with tmp_inp.open("w") as stream:
                stream.write(self.preprocess(inp.read_text()))

        if not out.parent.exists():
            raise FileNotFoundError(f"output directory does not exist!: {out.parent}")

        if out.is_dir():
            raise IsADirectoryError(out)

        tmp_out = tmp_root / out.name
        if tmp_out.exists():
            raise FileExistsError(tmp_out)

        if tmp_out.suffix.lower() not in self.VALID_OUT_EXT:
            raise ValueError(
                f"Expected output file to have a {', '.join(self.VALID_OUT_EXT)} extension, got {tmp_out.suffix}"
            )

        if tmp_inp.suffix.lower() not in {self.RAW_INPUT_EXT}:
            raise ValueError(f"Expected input file to have a .mmd extension, got {tmp_inp.suffix}")

        run(self.command(tmp_inp=tmp_inp, tmp_out=tmp_out, tmp_root=tmp_root, **kwargs), check=True)
        self.finalize(out=out, tmp_inp=tmp_inp, tmp_out=tmp_out, tmp_root=tmp_root, **kwargs)

command(*, tmp_inp, tmp_out, tmp_root, **kwargs)

Generate the command to run.

Parameters:

Name Type Description Default
tmp_inp Path

The input file, located in the temporary directory.

required
tmp_out Path

The output file, located in the temporary directory.

required
tmp_root Path

The current temporary directory.

required
kwargs Any

Additional keyword arguments.

{}

Yields:

Name Type Description
str str

The command strings that were generated.

Source code in jinja2_mermaid_extension/callback.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
def command(self, *, tmp_inp: Path, tmp_out: Path, tmp_root: Path, **kwargs: Any) -> Generator[str, None, None]:
    """
    Generate the command to run.

    Args:
        tmp_inp: The input file, located in the temporary directory.
        tmp_out: The output file, located in the temporary directory.
        tmp_root: The current temporary directory.
        kwargs: Additional keyword arguments.

    Yields:
        str: The command strings that were generated.
    """
    raise NotImplementedError

finalize(*, out, tmp_inp, tmp_out, tmp_root, **kwargs) staticmethod

Finalize the output file.

Parameters:

Name Type Description Default
out Path

The output file.

required
tmp_inp Path

The input file, located in the temporary directory.

required
tmp_out Path

The output file, located in the temporary directory.

required
tmp_root Path

The current temporary directory.

required
**kwargs Any

Additional keyword arguments.

{}

Returns:

Type Description
None

The finalized output file.

Source code in jinja2_mermaid_extension/callback.py
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
@staticmethod
def finalize(*, out: Path, tmp_inp: Path, tmp_out: Path, tmp_root: Path, **kwargs: Any) -> None:
    """
    Finalize the output file.

    Args:
        out: The output file.
        tmp_inp: The input file, located in the temporary directory.
        tmp_out: The output file, located in the temporary directory.
        tmp_root: The current temporary directory.
        **kwargs: Additional keyword arguments.

    Returns:
        The finalized output file.
    """
    if not tmp_out.exists():
        raise FileNotFoundError(tmp_out)

    shutil.copy(tmp_out, out)

preprocess(inp, **kwargs) staticmethod

Preprocess the input string.

Parameters:

Name Type Description Default
inp str

The input string.

required

Returns:

Name Type Description
str str

The preprocessed input string.

Source code in jinja2_mermaid_extension/callback.py
159
160
161
162
163
164
165
166
167
168
169
170
@staticmethod
def preprocess(inp: str, **kwargs: Any) -> str:
    """
    Preprocess the input string.

    Args:
        inp: The input string.

    Returns:
        str: The preprocessed input string.
    """
    return inp

TikZCallback

Bases: RunCommandInTempDir

A callback function for generating mermaid diagrams.

Source code in jinja2_mermaid_extension/callback.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
class TikZCallback(RunCommandInTempDir):
    """
    A callback function for generating mermaid diagrams.
    """

    #: The extension for raw input files.
    RAW_INPUT_EXT: ClassVar[str] = ".tex"
    #: The valid extensions for output files.
    VALID_OUT_EXT: ClassVar[frozenset[str]] = frozenset((".pdf", ".svg", ".png"))

    @staticmethod
    def preprocess(inp: str, **kwargs: Any) -> str:
        """
        Preprocess the input string.

        Args:
            inp: The input string.

        Returns:
            str: The preprocessed input string.
        """
        opts = TikZOptions(**kwargs)

        if "documentclass" not in inp:
            rendered = env().get_template("tikz.tex").render(**asdict(opts), inp=inp.rstrip())
            logger.debug("\n%s", rendered)
            return rendered

        return inp

    def command(self, *, tmp_inp: Path, tmp_out: Path, tmp_root: Path, **kwargs: Any) -> Generator[str, None, None]:
        """
        Generate the command to run.

        Args:
            tmp_inp: The input file, located in the temporary directory.
            tmp_out: The output file, located in the temporary directory.
            tmp_root: The current temporary directory.
            kwargs: Additional keyword arguments.

        Yields:
            str: The command strings that were generated.
        """
        opts = TikZOptions(**kwargs)

        if opts.latex_command and not has_tool(opts.latex_command[0]):
            if opts.allow_missing:
                yield "echo"
                yield "Skipping tectonic command because it is not found."

            raise FileNotFoundError(opts.latex_command[0])

        for command in opts.latex_command:
            yield command.format(inp_tex=tmp_inp)

    @classmethod
    def finalize(cls, *, out: Path, tmp_inp: Path, tmp_out: Path, tmp_root: Path, **kwargs: Any) -> None:
        """
        Finalize the output file.
        """
        opts = TikZOptions(**kwargs)

        if tmp_out.suffix.lower() == ".svg":
            cls._handle_pdf_to_svg(opts, out, tmp_out)
        elif tmp_out.suffix.lower() == ".png":
            cls._handle_pdf_to_png(opts, out, tmp_out)
        else:
            shutil.copy(tmp_out, out)

    @staticmethod
    def _handle_pdf_to_svg(opts: TikZOptions, out: Path, tmp_out: Path) -> None:
        args: dict[str, Any] = {"inp_pdf": tmp_out.with_suffix(".pdf"), "out_svg": tmp_out}
        command = [c.format(**args) for c in opts.pdf2svg_command]
        if command and has_tool(command[0]):
            run(command, check=True)
            shutil.copy(tmp_out, out)
        else:
            if not opts.allow_missing:
                raise FileNotFoundError("command not found")

    @staticmethod
    def _handle_pdf_to_png(opts: TikZOptions, out: Path, tmp_out: Path) -> None:
        args: dict[str, Any] = {
            "inp_pdf": tmp_out.with_suffix(".pdf"),
            "out_png": tmp_out,
            "density": str(opts.convert_command_density),
        }
        command = [c.format(**args) for c in opts.convert_command]
        if command and has_tool(command[0]):
            run(command, check=True)
            shutil.copy(tmp_out, out)
        else:
            if not opts.allow_missing:
                raise FileNotFoundError("command not found")

command(*, tmp_inp, tmp_out, tmp_root, **kwargs)

Generate the command to run.

Parameters:

Name Type Description Default
tmp_inp Path

The input file, located in the temporary directory.

required
tmp_out Path

The output file, located in the temporary directory.

required
tmp_root Path

The current temporary directory.

required
kwargs Any

Additional keyword arguments.

{}

Yields:

Name Type Description
str str

The command strings that were generated.

Source code in jinja2_mermaid_extension/callback.py
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
def command(self, *, tmp_inp: Path, tmp_out: Path, tmp_root: Path, **kwargs: Any) -> Generator[str, None, None]:
    """
    Generate the command to run.

    Args:
        tmp_inp: The input file, located in the temporary directory.
        tmp_out: The output file, located in the temporary directory.
        tmp_root: The current temporary directory.
        kwargs: Additional keyword arguments.

    Yields:
        str: The command strings that were generated.
    """
    opts = TikZOptions(**kwargs)

    if opts.latex_command and not has_tool(opts.latex_command[0]):
        if opts.allow_missing:
            yield "echo"
            yield "Skipping tectonic command because it is not found."

        raise FileNotFoundError(opts.latex_command[0])

    for command in opts.latex_command:
        yield command.format(inp_tex=tmp_inp)

finalize(*, out, tmp_inp, tmp_out, tmp_root, **kwargs) classmethod

Finalize the output file.

Source code in jinja2_mermaid_extension/callback.py
312
313
314
315
316
317
318
319
320
321
322
323
324
@classmethod
def finalize(cls, *, out: Path, tmp_inp: Path, tmp_out: Path, tmp_root: Path, **kwargs: Any) -> None:
    """
    Finalize the output file.
    """
    opts = TikZOptions(**kwargs)

    if tmp_out.suffix.lower() == ".svg":
        cls._handle_pdf_to_svg(opts, out, tmp_out)
    elif tmp_out.suffix.lower() == ".png":
        cls._handle_pdf_to_png(opts, out, tmp_out)
    else:
        shutil.copy(tmp_out, out)

preprocess(inp, **kwargs) staticmethod

Preprocess the input string.

Parameters:

Name Type Description Default
inp str

The input string.

required

Returns:

Name Type Description
str str

The preprocessed input string.

Source code in jinja2_mermaid_extension/callback.py
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
@staticmethod
def preprocess(inp: str, **kwargs: Any) -> str:
    """
    Preprocess the input string.

    Args:
        inp: The input string.

    Returns:
        str: The preprocessed input string.
    """
    opts = TikZOptions(**kwargs)

    if "documentclass" not in inp:
        rendered = env().get_template("tikz.tex").render(**asdict(opts), inp=inp.rstrip())
        logger.debug("\n%s", rendered)
        return rendered

    return inp

TikZOptions dataclass

Bases: Options

Specific options for the tikz callback function.

Source code in jinja2_mermaid_extension/callback.py
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
@dataclass
class TikZOptions(Options):
    """
    Specific options for the tikz callback function.
    """

    #: Allow commands to be missing?
    allow_missing: bool = field(
        default_factory=lambda: os.environ.get("JINJA2_MERMAID_EXTENSION_ALLOW_MISSING_COMMANDS", "0").lower()
        in {"1", "true"}
    )

    #: The commands to run to generate the LaTeX output.
    latex_command: tuple[str, ...] = (
        "tectonic",
        "{inp_tex}",
    )

    #: The commands to run to generate the SVG output.
    pdf2svg_command: tuple[str, ...] = (
        "pdf2svg",
        "{inp_pdf}",
        "{out_svg}",
    )

    #: The commands to run to generate the PNG output.
    convert_command: tuple[str, ...] = (
        "magick",
        "convert",
        "-density",
        "{density}",
        "{inp_pdf}",
        "{out_png}",
    )

    #: The DPI to use for the PNG output.
    convert_command_density: int = 300

    # The following options are used when the input does not explicitly configure the documentclass.

    #: The LaTeX packages to include.
    packages: tuple[str, ...] = ("xcolor", "tikz")
    #: The LaTeX preamble to include.
    preamble: str = ""
    #: The tikz libraries to include.
    libraries: tuple[str, ...] = ("shapes", "arrows", "decorations", "positioning", "patterns", "calc")
    #: The tikz picture options to use.
    tikz_options: tuple[str, ...] = ("scale=1", "remember picture")

env() cached

Get the Jinja2 environment.

Returns:

Name Type Description
Environment Environment

The Jinja2 environment.

Source code in jinja2_mermaid_extension/callback.py
21
22
23
24
25
26
27
28
29
@functools.lru_cache(maxsize=1)
def env() -> Environment:
    """
    Get the Jinja2 environment.

    Returns:
        Environment: The Jinja2 environment.
    """
    return Environment(loader=PackageLoader("jinja2_mermaid_extension", "templates"))  # noqa: S701

handle_temp_root(force, delete_temp_dir)

Handle the temporary root directory.

Parameters:

Name Type Description Default
force Path | None

A forced temporary root directory.

required
delete_temp_dir bool

Whether to delete the temporary directory after execution.

required

Yields:

Name Type Description
Path Path

The temporary root directory.

Source code in jinja2_mermaid_extension/callback.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
@contextmanager
def handle_temp_root(force: Path | None, delete_temp_dir: bool) -> Generator[Path, None, None]:
    """
    Handle the temporary root directory.

    Args:
        force: A forced temporary root directory.
        delete_temp_dir: Whether to delete the temporary directory after execution.

    Yields:
        Path: The temporary root directory.
    """
    try:
        if force:
            yield force
        else:
            with TemporaryDirectory(delete=delete_temp_dir) as tmp_root:
                yield Path(tmp_root)
    finally:
        pass

has_tool(command) cached

Check if a command is available on the system.

Parameters:

Name Type Description Default
command str

The command to check.

required

Returns:

Name Type Description
bool bool

True if the command is available, False otherwise.

Source code in jinja2_mermaid_extension/callback.py
32
33
34
35
36
37
38
39
40
41
42
43
@functools.lru_cache
def has_tool(command: str) -> bool:
    """
    Check if a command is available on the system.

    Args:
        command: The command to check.

    Returns:
        bool: True if the command is available, False otherwise.
    """
    return shutil.which(command) is not None

This module defines a jinja2 extension for generating mermaid diagrams.

MermaidExtension

Bases: GenImageExtension

A Jinja2 extension for generating mermaid diagrams.

Source code in jinja2_mermaid_extension/extension.py
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
class MermaidExtension(GenImageExtension):
    """
    A Jinja2 extension for generating mermaid diagrams.
    """

    tags: set[str] = {"mermaid"}  # noqa: RUF012
    input_root_key: str | None = "mermaid_input_root"
    output_root_key: str | None = "mermaid_output_root"

    def __init__(self, environment: Environment):
        super().__init__(environment)

    @property
    def _valid_keys(self) -> Generator[str]:
        yield from MermaidOptions.__annotations__.keys()
        yield from inspect.signature(mermaid).parameters

    @staticmethod
    def modify(**kwargs: Any) -> Generator[tuple[str, Any], None, None]:
        """
        Intercept and modify the keyword arguments before passing them to the callback function.
        """
        for key, value in kwargs.items():
            if key == "diagram":
                if "inp" in kwargs:
                    raise RuntimeError("Cannot have both 'diagram' and 'inp' in kwargs")
                yield "inp", value
            else:
                yield key, value

    def callback(
        self,
        inp: Path | str,
        out: Path,
        inp_root: Path,
        out_root: Path,
        **kwargs: Any,
    ) -> None:
        """
        The function to call to generate an image.
        """
        if isinstance(inp, str) and inp.endswith(".mmd"):
            inp = Path(inp)
            if not inp.is_absolute():
                inp = inp_root / inp

        return mermaid(inp=inp, out=out, **kwargs)

callback(inp, out, inp_root, out_root, **kwargs)

The function to call to generate an image.

Source code in jinja2_mermaid_extension/extension.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def callback(
    self,
    inp: Path | str,
    out: Path,
    inp_root: Path,
    out_root: Path,
    **kwargs: Any,
) -> None:
    """
    The function to call to generate an image.
    """
    if isinstance(inp, str) and inp.endswith(".mmd"):
        inp = Path(inp)
        if not inp.is_absolute():
            inp = inp_root / inp

    return mermaid(inp=inp, out=out, **kwargs)

modify(**kwargs) staticmethod

Intercept and modify the keyword arguments before passing them to the callback function.

Source code in jinja2_mermaid_extension/extension.py
82
83
84
85
86
87
88
89
90
91
92
93
@staticmethod
def modify(**kwargs: Any) -> Generator[tuple[str, Any], None, None]:
    """
    Intercept and modify the keyword arguments before passing them to the callback function.
    """
    for key, value in kwargs.items():
        if key == "diagram":
            if "inp" in kwargs:
                raise RuntimeError("Cannot have both 'diagram' and 'inp' in kwargs")
            yield "inp", value
        else:
            yield key, value

TikZExtension

Bases: GenImageExtension

A Jinja2 extension for generating tikz diagrams.

Source code in jinja2_mermaid_extension/extension.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
class TikZExtension(GenImageExtension):
    """
    A Jinja2 extension for generating tikz diagrams.
    """

    tags: set[str] = {"tikz"}  # noqa: RUF012
    input_root_key: str | None = "tikz_input_root"
    output_root_key: str | None = "tikz_output_root"

    def __init__(self, environment: Environment):
        super().__init__(environment)

    @property
    def _valid_keys(self) -> Generator[str]:
        yield from TikZOptions.__annotations__.keys()
        yield from inspect.signature(tikz).parameters

    @staticmethod
    def modify(**kwargs: Any) -> Generator[tuple[str, Any], None, None]:
        """
        Intercept and modify the keyword arguments before passing them to the callback function.
        """
        for key, value in kwargs.items():
            if key == "diagram":
                if "inp" in kwargs:
                    raise RuntimeError("Cannot have both 'diagram' and 'inp' in kwargs")
                yield "inp", value
            else:
                yield key, value

    def callback(
        self,
        inp: Path | str,
        out: Path,
        inp_root: Path,
        out_root: Path,
        **kwargs: Any,
    ) -> None:
        """
        The function to call to generate an image.
        """
        if isinstance(inp, str) and inp.endswith(".tex"):
            inp = Path(inp)
            if not inp.is_absolute():
                inp = inp_root / inp

        return tikz(inp=inp, out=out, **kwargs)

callback(inp, out, inp_root, out_root, **kwargs)

The function to call to generate an image.

Source code in jinja2_mermaid_extension/extension.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def callback(
    self,
    inp: Path | str,
    out: Path,
    inp_root: Path,
    out_root: Path,
    **kwargs: Any,
) -> None:
    """
    The function to call to generate an image.
    """
    if isinstance(inp, str) and inp.endswith(".tex"):
        inp = Path(inp)
        if not inp.is_absolute():
            inp = inp_root / inp

    return tikz(inp=inp, out=out, **kwargs)

modify(**kwargs) staticmethod

Intercept and modify the keyword arguments before passing them to the callback function.

Source code in jinja2_mermaid_extension/extension.py
33
34
35
36
37
38
39
40
41
42
43
44
@staticmethod
def modify(**kwargs: Any) -> Generator[tuple[str, Any], None, None]:
    """
    Intercept and modify the keyword arguments before passing them to the callback function.
    """
    for key, value in kwargs.items():
        if key == "diagram":
            if "inp" in kwargs:
                raise RuntimeError("Cannot have both 'diagram' and 'inp' in kwargs")
            yield "inp", value
        else:
            yield key, value