laudo 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
laudo-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.3
2
+ Name: laudo
3
+ Version: 0.1.0
4
+ Summary: Generate forensic report using docxtpl templates
5
+ Author: renatormc
6
+ Author-email: renatormc <renatomartinsrmc@gmail.com>
7
+ License: MIT
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Legal Industry
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Topic :: Office/Business :: Office Suites
15
+ Requires-Dist: beautifulsoup4>=4.14.3
16
+ Requires-Dist: docxtpl[subdoc]>=0.20.2
17
+ Requires-Dist: piexif>=1.1
18
+ Requires-Dist: pillow>=10.0
19
+ Requires-Dist: pyside6>=6.6
20
+ Requires-Dist: python-dateutil>=2.9.0.post0
21
+ Requires-Dist: python-docx-replace>=0.4.4
22
+ Requires-Python: >=3.13
23
+ Project-URL: Homepage, https://github.com/renatormc/laudo
24
+ Project-URL: Repository, https://github.com/renatormc/laudo
25
+ Project-URL: Issues, https://github.com/renatormc/laudo/issues
26
+ Description-Content-Type: text/markdown
27
+
28
+ # laudo
29
+
30
+ Convert markdown files to **docx** or **pdf** using `docxtpl` templates and LibreOffice headless.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install laudo
36
+ ```
37
+
38
+ Requires **Python 3.13+** and **LibreOffice** installed system-wide for PDF output.
39
+
40
+ ## Usage
41
+
42
+ ### CLI
43
+
44
+ ```bash
45
+ laudo <folder> <output>
46
+ ```
47
+
48
+ - `<folder>` — directory with your project files.
49
+ - `<output>` — output file (`.docx` or `.pdf`).
50
+
51
+ ### Python API
52
+
53
+ ```python
54
+ from laudo import convert
55
+
56
+ convert("my_project/", "output.docx")
57
+ convert("my_project/", "output.pdf")
58
+ ```
59
+
60
+ ## Input Folder Structure
61
+
62
+ ```
63
+ my_project/
64
+ ├── template.docx # docxtpl template with Jinja2 placeholders
65
+ ├── fotos/ # Images with captions (EXIF)
66
+ │ ├── logo.png
67
+ │ └── photo.jpg
68
+ ├── context.txt # (optional) Variables one per line
69
+ ├── intro.md # One or more .md files
70
+ └── chapter-1.md
71
+ ```
72
+
73
+ ### `template.docx`
74
+
75
+ A standard Word document using [docxtpl](https://github.com/elapouya/python-docxtpl) syntax. Use Jinja2 placeholders like `{{ variable }}` or `{{p intro|markdown }}`.
76
+
77
+ ### `context.txt`
78
+
79
+ Optional file with variables in `key = value` format, one per line:
80
+
81
+ ```
82
+ author = John Doe
83
+ title = My Document
84
+ ```
85
+
86
+ Lines starting with `#` and empty lines are ignored.
87
+
88
+ These variables are also available for substitution inside your `.md` files (see below).
89
+
90
+ ### Markdown files
91
+
92
+ Every `.md` file is read and its content is injected into the template context. The variable name is the filename without extension (whitespace replaced by underscores):
93
+
94
+ | File | Context key |
95
+ |---|---|
96
+ | `intro.md` | `intro` |
97
+ | `chapter 1.md` | `chapter_1` |
98
+
99
+ You can use `{{ varname }}` inside `.md` files to reference values from `context.txt`:
100
+
101
+ **context.txt:**
102
+ ```
103
+ name = John Doe
104
+ ```
105
+
106
+ **intro.md:**
107
+ ```markdown
108
+ # Hello {{ name }}
109
+ ```
110
+
111
+ The result will be `# Hello John Doe`, then stored in context key `intro`.
112
+
113
+ Use the `subdoc` global function to embed `.docx` files from the `assets/` folder:
114
+
115
+ ```jinja2
116
+ {{p subdoc("cover.docx") }}
117
+ ```
118
+
119
+ Use the `markdown` filter in your template:
120
+
121
+ ```jinja2
122
+ {{p intro|markdown }}
123
+ ```
124
+
125
+ ### Fotos
126
+
127
+ All image files inside `fotos/` are listed in the `pics` context variable:
128
+
129
+ ```json
130
+ {
131
+ "logo": {"path": "fotos/logo.png", "caption": "", "thumb": "fotos/.thumbs/logo_thumb.jpg", "reduced": "fotos/.thumbs/logo_reduced.jpg"},
132
+ "photo": {"path": "fotos/photo.jpg", "caption": "", "thumb": "fotos/.thumbs/photo_thumb.jpg", "reduced": "fotos/.thumbs/photo_reduced.jpg"}
133
+ }
134
+ ```
135
+
136
+ Captions are read from EXIF `ImageDescription` metadata.
137
+
138
+ ## PDF Output
139
+
140
+ When the output path ends with `.pdf`, a docx is generated first and then converted to PDF via LibreOffice headless:
141
+
142
+ ```bash
143
+ laudo my_project/ output.pdf
144
+ ```
145
+
146
+ The intermediate docx file is automatically removed.
147
+
148
+ ## License
149
+
150
+ MIT
laudo-0.1.0/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # laudo
2
+
3
+ Convert markdown files to **docx** or **pdf** using `docxtpl` templates and LibreOffice headless.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install laudo
9
+ ```
10
+
11
+ Requires **Python 3.13+** and **LibreOffice** installed system-wide for PDF output.
12
+
13
+ ## Usage
14
+
15
+ ### CLI
16
+
17
+ ```bash
18
+ laudo <folder> <output>
19
+ ```
20
+
21
+ - `<folder>` — directory with your project files.
22
+ - `<output>` — output file (`.docx` or `.pdf`).
23
+
24
+ ### Python API
25
+
26
+ ```python
27
+ from laudo import convert
28
+
29
+ convert("my_project/", "output.docx")
30
+ convert("my_project/", "output.pdf")
31
+ ```
32
+
33
+ ## Input Folder Structure
34
+
35
+ ```
36
+ my_project/
37
+ ├── template.docx # docxtpl template with Jinja2 placeholders
38
+ ├── fotos/ # Images with captions (EXIF)
39
+ │ ├── logo.png
40
+ │ └── photo.jpg
41
+ ├── context.txt # (optional) Variables one per line
42
+ ├── intro.md # One or more .md files
43
+ └── chapter-1.md
44
+ ```
45
+
46
+ ### `template.docx`
47
+
48
+ A standard Word document using [docxtpl](https://github.com/elapouya/python-docxtpl) syntax. Use Jinja2 placeholders like `{{ variable }}` or `{{p intro|markdown }}`.
49
+
50
+ ### `context.txt`
51
+
52
+ Optional file with variables in `key = value` format, one per line:
53
+
54
+ ```
55
+ author = John Doe
56
+ title = My Document
57
+ ```
58
+
59
+ Lines starting with `#` and empty lines are ignored.
60
+
61
+ These variables are also available for substitution inside your `.md` files (see below).
62
+
63
+ ### Markdown files
64
+
65
+ Every `.md` file is read and its content is injected into the template context. The variable name is the filename without extension (whitespace replaced by underscores):
66
+
67
+ | File | Context key |
68
+ |---|---|
69
+ | `intro.md` | `intro` |
70
+ | `chapter 1.md` | `chapter_1` |
71
+
72
+ You can use `{{ varname }}` inside `.md` files to reference values from `context.txt`:
73
+
74
+ **context.txt:**
75
+ ```
76
+ name = John Doe
77
+ ```
78
+
79
+ **intro.md:**
80
+ ```markdown
81
+ # Hello {{ name }}
82
+ ```
83
+
84
+ The result will be `# Hello John Doe`, then stored in context key `intro`.
85
+
86
+ Use the `subdoc` global function to embed `.docx` files from the `assets/` folder:
87
+
88
+ ```jinja2
89
+ {{p subdoc("cover.docx") }}
90
+ ```
91
+
92
+ Use the `markdown` filter in your template:
93
+
94
+ ```jinja2
95
+ {{p intro|markdown }}
96
+ ```
97
+
98
+ ### Fotos
99
+
100
+ All image files inside `fotos/` are listed in the `pics` context variable:
101
+
102
+ ```json
103
+ {
104
+ "logo": {"path": "fotos/logo.png", "caption": "", "thumb": "fotos/.thumbs/logo_thumb.jpg", "reduced": "fotos/.thumbs/logo_reduced.jpg"},
105
+ "photo": {"path": "fotos/photo.jpg", "caption": "", "thumb": "fotos/.thumbs/photo_thumb.jpg", "reduced": "fotos/.thumbs/photo_reduced.jpg"}
106
+ }
107
+ ```
108
+
109
+ Captions are read from EXIF `ImageDescription` metadata.
110
+
111
+ ## PDF Output
112
+
113
+ When the output path ends with `.pdf`, a docx is generated first and then converted to PDF via LibreOffice headless:
114
+
115
+ ```bash
116
+ laudo my_project/ output.pdf
117
+ ```
118
+
119
+ The intermediate docx file is automatically removed.
120
+
121
+ ## License
122
+
123
+ MIT
@@ -0,0 +1,53 @@
1
+ [project]
2
+ name = "laudo"
3
+ version = "0.1.0"
4
+ description = "Generate forensic report using docxtpl templates"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "renatormc", email = "renatomartinsrmc@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.13"
10
+ license = { text = "MIT" }
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Environment :: Console",
14
+ "Intended Audience :: Legal Industry",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Topic :: Office/Business :: Office Suites",
19
+ ]
20
+ dependencies = [
21
+ "beautifulsoup4>=4.14.3",
22
+ "docxtpl[subdoc]>=0.20.2",
23
+ "PieXif>=1.1",
24
+ "Pillow>=10.0",
25
+ "PySide6>=6.6",
26
+ "python-dateutil>=2.9.0.post0",
27
+ "python-docx-replace>=0.4.4",
28
+ ]
29
+
30
+ [project.scripts]
31
+ laudo = "laudo.__main__:main"
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/renatormc/laudo"
35
+ Repository = "https://github.com/renatormc/laudo"
36
+ Issues = "https://github.com/renatormc/laudo/issues"
37
+
38
+ [dependency-groups]
39
+ dev = [
40
+ "mypy>=1.20.2",
41
+ "pyinstaller>=6.20.0",
42
+ "pytest>=8.0",
43
+ "types-beautifulsoup4>=4.12.0.20250516",
44
+ "types-pillow",
45
+ "types-python-dateutil>=2.9.0.20260508",
46
+ ]
47
+
48
+ [tool.mypy]
49
+ ignore_missing_imports = true
50
+
51
+ [build-system]
52
+ requires = ["uv_build>=0.10.8,<0.11.0"]
53
+ build-backend = "uv_build"
@@ -0,0 +1,7 @@
1
+ from pathlib import Path
2
+
3
+ from .core import run
4
+
5
+
6
+ def convert(folder: str | Path, output: str | Path, *, debug: bool = False) -> Path:
7
+ return run(Path(folder), Path(output), debug=debug)
@@ -0,0 +1,113 @@
1
+ import argparse
2
+ import os
3
+ import stat
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from . import convert
8
+
9
+
10
+ def _find_project_root() -> Path:
11
+ candidate = Path(__file__).resolve()
12
+ for parent in [candidate] + list(candidate.parents):
13
+ if (parent / "pyproject.toml").is_file():
14
+ return parent
15
+ return candidate.parent
16
+
17
+
18
+ def _cmd_install() -> None:
19
+ bindir = Path.home() / ".local" / "bin"
20
+ bindir.mkdir(parents=True, exist_ok=True)
21
+ project_root = _find_project_root()
22
+
23
+ if os.name == "nt":
24
+ script = bindir / "laudo-dev.ps1"
25
+ content = f"& uv run --project {project_root} laudo @args\n"
26
+ script.write_text(content.lstrip())
27
+ print(f"Installed laudo wrapper at {script}")
28
+ path_sep = ";"
29
+ path_hint = f"$env:Path = \"{bindir};$env:Path\""
30
+ else:
31
+ script = bindir / "laudo"
32
+ content = f"#!/usr/bin/env bash\nexec uv run --project {project_root} laudo \"$@\"\n"
33
+ script.write_text(content.lstrip())
34
+ script.chmod(script.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
35
+ print(f"Installed laudo wrapper at {script}")
36
+ path_sep = ":"
37
+ path_hint = f'export PATH="$PATH:{bindir}"'
38
+
39
+ if str(bindir) not in os.environ.get("PATH", "").split(path_sep):
40
+ print(f"Warning: {bindir} is not in your PATH.", file=sys.stderr)
41
+ if os.name == "nt":
42
+ print("Add this to your PowerShell profile:")
43
+ print(f" {path_hint}")
44
+ else:
45
+ shell = Path(os.environ.get("SHELL", "/bin/bash")).name
46
+ rc: Path | None = (
47
+ Path.home() / ".bashrc"
48
+ if shell == "bash"
49
+ else Path.home() / ".zshrc"
50
+ if shell == "zsh"
51
+ else None
52
+ )
53
+ if rc is not None:
54
+ print(f"Add this line to {rc}:")
55
+ print(f" {path_hint}")
56
+
57
+
58
+ def _cmd_gen(dir_: Path | None, debug: bool) -> None:
59
+ if dir_ is not None:
60
+ os.chdir(str(dir_))
61
+ folder = Path.cwd()
62
+ output = folder / "laudo.docx"
63
+ result = convert(folder, output, debug=debug)
64
+ print(f"Generated: {result}")
65
+
66
+
67
+ def _cmd_captions(dir_: Path | None) -> None:
68
+ if dir_ is not None:
69
+ os.chdir(str(dir_))
70
+ from .gui import run as run_gui
71
+
72
+ run_gui(Path.cwd())
73
+
74
+
75
+ def _existing_dir(value: str) -> Path:
76
+ p = Path(value)
77
+ if not p.is_dir():
78
+ raise argparse.ArgumentTypeError(f"not a directory: {value}")
79
+ return p
80
+
81
+
82
+ def main() -> None:
83
+ parser = argparse.ArgumentParser(
84
+ description="Convert markdown files or open the caption editor."
85
+ )
86
+ sub = parser.add_subparsers(dest="command")
87
+
88
+ gen_p = sub.add_parser("gen", help="Generate docx/pdf from markdown files.")
89
+ gen_p.add_argument("--dir", type=_existing_dir, default=None, help="Working directory (default: current directory)")
90
+ gen_p.add_argument("--debug", action="store_true", help="Print the template context before rendering")
91
+
92
+ cap_p = sub.add_parser("captions", help="Open the caption editor GUI.")
93
+ cap_p.add_argument("--dir", type=_existing_dir, default=None, help="Working directory (default: current directory)")
94
+
95
+ sub.add_parser("install", help="Install the laudo wrapper script to ~/.local/bin.")
96
+
97
+ args = parser.parse_args()
98
+
99
+ if args.command is None:
100
+ parser.print_help()
101
+ sys.exit(1)
102
+
103
+ match args.command:
104
+ case "install":
105
+ _cmd_install()
106
+ case "gen":
107
+ _cmd_gen(args.dir, args.debug)
108
+ case "captions":
109
+ _cmd_captions(args.dir)
110
+
111
+
112
+ if __name__ == "__main__":
113
+ main()
@@ -0,0 +1,14 @@
1
+ from pathlib import Path
2
+
3
+ from .exif import get_caption
4
+
5
+
6
+ def collect_assets(assets_path: Path) -> dict[str, dict]:
7
+ assets: dict[str, dict] = {}
8
+ if not assets_path.is_dir():
9
+ return assets
10
+ for f in sorted(assets_path.iterdir()):
11
+ if f.is_file():
12
+ caption = get_caption(f) if f.suffix.lower() in {".jpg", ".jpeg", ".tiff", ".png"} else ""
13
+ assets[f.stem] = {"path": f, "caption": caption}
14
+ return assets
@@ -0,0 +1,141 @@
1
+ import os
2
+ import shutil
3
+ import tempfile
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ import json
7
+ from docxtpl import DocxTemplate
8
+ from jinja2 import Environment, Template
9
+
10
+ from laudo.reference_replacer import DocxReferenceReplacer
11
+
12
+ from .filters import register as register_filters
13
+ from .globals import register as register_globals
14
+
15
+
16
+ def _parse_context_txt(folder: Path) -> dict:
17
+ context: dict = {}
18
+ ctx_file = folder / "context.txt"
19
+ if not ctx_file.is_file():
20
+ return context
21
+ for line in ctx_file.read_text(encoding="utf-8").splitlines():
22
+ line = line.strip()
23
+ if not line or "=" not in line:
24
+ continue
25
+ key, _, val = line.partition("=")
26
+ key = key.strip()
27
+ if not key:
28
+ continue
29
+ context[key] = val.strip()
30
+ return context
31
+
32
+
33
+ def _read_context_json(folder: Path) -> dict:
34
+ ctx: dict = {}
35
+ for json_file in sorted(folder.glob("*.json")):
36
+ key = json_file.stem.replace(" ", "_")
37
+ content = json_file.read_text(encoding="utf-8")
38
+ ctx[key] = json.loads(content)
39
+ return ctx
40
+
41
+
42
+ def _read_context_markdown(folder: Path, ctx_vars: dict) -> dict:
43
+ ctx: dict = {}
44
+ for md_file in sorted(folder.glob("*.md")):
45
+ key = md_file.stem.replace(" ", "_")
46
+ content = md_file.read_text(encoding="utf-8")
47
+ ctx[key] = Template(content).render(ctx_vars)
48
+ return ctx
49
+
50
+
51
+ def _read_context_pics(folder: Path) -> list[dict]:
52
+ from .exif import get_caption as _get_exif_caption
53
+ from .images import _IMAGE_EXTENSIONS, get_reduced, get_thumbnail
54
+
55
+ fotos = folder / "fotos"
56
+ if not fotos.is_dir():
57
+ return []
58
+
59
+ cwd = Path.cwd()
60
+ os.chdir(str(folder))
61
+ try:
62
+ pics: list[dict] = []
63
+ for img in sorted(fotos.iterdir()):
64
+ if img.is_file() and img.suffix.lower() in _IMAGE_EXTENSIONS:
65
+ pics.append({
66
+ "refname": img.stem,
67
+ "path": img,
68
+ "caption": _get_exif_caption(img),
69
+ "thumb": get_thumbnail(img.stem),
70
+ "reduced": get_reduced(img.stem),
71
+ "label": "Foto",
72
+ })
73
+ return pics
74
+ finally:
75
+ os.chdir(str(cwd))
76
+
77
+
78
+ def _build_context(folder: Path) -> dict:
79
+ context_folder = folder / "context"
80
+ if not context_folder.is_dir():
81
+ context_folder = folder
82
+ ctx_vars = _parse_context_txt(context_folder)
83
+ ctx_vars.update(_read_context_json(context_folder))
84
+ ctx_vars.update(_read_context_markdown(context_folder, ctx_vars))
85
+ pics = _read_context_pics(folder)
86
+ if pics:
87
+ ctx_vars["pics"] = pics
88
+ return ctx_vars
89
+
90
+ @dataclass
91
+ class RenderEnv:
92
+ tpl: DocxTemplate
93
+ jinja_env: Environment
94
+ temp_folder: Path
95
+ assets_folder: Path
96
+
97
+ def render_docx(template_path: Path, context: dict, output_path: Path, replace_references=True) -> Path:
98
+ temp_folder = Path(tempfile.mkdtemp(prefix="laudo_"))
99
+ renv = RenderEnv(
100
+ tpl=DocxTemplate(str(template_path)),
101
+ jinja_env=Environment(),
102
+ temp_folder=temp_folder,
103
+ assets_folder=template_path.parent / "assets",
104
+ )
105
+ try:
106
+ register_filters(renv)
107
+ register_globals(renv)
108
+ renv.tpl.render(context, jinja_env=renv.jinja_env)
109
+ if replace_references:
110
+ dr = DocxReferenceReplacer()
111
+ dr.replace_in_doc(renv.tpl.docx)
112
+ renv.tpl.save(str(output_path))
113
+ return output_path
114
+ finally:
115
+ shutil.rmtree(temp_folder, ignore_errors=True)
116
+
117
+
118
+ def run(folder: Path, output: Path, *, debug: bool = False) -> Path:
119
+ template_path = folder / "template.docx"
120
+ print(template_path)
121
+ if not template_path.is_file():
122
+ raise FileNotFoundError(f"template.docx not found in project folder or in package templates")
123
+
124
+
125
+ context = _build_context(folder)
126
+ if debug:
127
+ import pprint
128
+ print("--- context ---")
129
+ pprint.pprint(context, indent=2, sort_dicts=False)
130
+ print("---------------")
131
+
132
+ if output.suffix == ".pdf":
133
+ docx_path = output.with_suffix(".docx")
134
+ render_docx(template_path, context, docx_path)
135
+ from .pdf import convert_to_pdf
136
+
137
+ result = convert_to_pdf(docx_path, output)
138
+ # docx_path.unlink()
139
+ return result
140
+
141
+ return render_docx(template_path, context, output)
@@ -0,0 +1,58 @@
1
+ from pathlib import Path
2
+
3
+ import piexif # type: ignore[import-untyped]
4
+ from PIL import Image
5
+
6
+
7
+ _IMAGE_DESC_TAG = piexif.ImageIFD.ImageDescription
8
+
9
+
10
+ def _sanitize(exif_dict: dict) -> dict:
11
+ """Convert Undefined-type tuples back to bytes for piexif.dump."""
12
+ for ifd_name in ("0th", "Exif", "GPS", "1st"):
13
+ ifd = exif_dict.get(ifd_name, {})
14
+ info = piexif.TAGS.get(ifd_name, {})
15
+ for tag, value in ifd.items():
16
+ if isinstance(value, tuple) and info.get(tag, {}).get("type") == 7:
17
+ ifd[tag] = bytes(value)
18
+ return exif_dict
19
+
20
+
21
+ def get_caption(path: Path) -> str:
22
+ """Read caption from the EXIF ImageDescription tag."""
23
+ try:
24
+ exif_dict = piexif.load(str(path))
25
+ raw = exif_dict.get("0th", {}).get(_IMAGE_DESC_TAG)
26
+ if raw is None:
27
+ return ""
28
+ if isinstance(raw, bytes):
29
+ return raw.decode("utf-8", errors="replace")
30
+ return str(raw)
31
+ except Exception:
32
+ return ""
33
+
34
+
35
+ def set_caption(path: Path, caption: str) -> None:
36
+ """Write caption to the EXIF ImageDescription tag (in-place)."""
37
+ exif_dict = piexif.load(str(path))
38
+ exif_dict["0th"][_IMAGE_DESC_TAG] = caption.encode("utf-8")
39
+ exif_bytes = piexif.dump(_sanitize(exif_dict))
40
+ _save_exif(path, exif_bytes)
41
+
42
+
43
+ def clear_caption(path: Path) -> None:
44
+ """Remove the EXIF ImageDescription tag."""
45
+ exif_dict = piexif.load(str(path))
46
+ exif_dict["0th"].pop(_IMAGE_DESC_TAG, None)
47
+ exif_bytes = piexif.dump(_sanitize(exif_dict))
48
+ _save_exif(path, exif_bytes)
49
+
50
+
51
+ def has_caption(path: Path) -> bool:
52
+ """Check if the image has a non-empty caption."""
53
+ return bool(get_caption(path))
54
+
55
+
56
+ def _save_exif(path: Path, exif_bytes: bytes) -> None:
57
+ with Image.open(path) as img:
58
+ img.save(path, exif=exif_bytes)
@@ -0,0 +1,17 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from .filters import Filters
4
+ from laudo.filters.markdown_filter import MarkdownFilter
5
+ if TYPE_CHECKING:
6
+ from laudo.core import RenderEnv
7
+
8
+
9
+ def register(rend: 'RenderEnv') -> None:
10
+ rend.jinja_env.filters["markdown"] = MarkdownFilter(rend)
11
+ fs = Filters()
12
+ for name in dir(fs):
13
+ if not name.startswith("_"):
14
+ method = getattr(fs, name)
15
+ if callable(method):
16
+ rend.jinja_env.filters[name] = method
17
+