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 +150 -0
- laudo-0.1.0/README.md +123 -0
- laudo-0.1.0/pyproject.toml +53 -0
- laudo-0.1.0/src/laudo/__init__.py +7 -0
- laudo-0.1.0/src/laudo/__main__.py +113 -0
- laudo-0.1.0/src/laudo/assets.py +14 -0
- laudo-0.1.0/src/laudo/core.py +141 -0
- laudo-0.1.0/src/laudo/exif.py +58 -0
- laudo-0.1.0/src/laudo/filters/__init__.py +17 -0
- laudo-0.1.0/src/laudo/filters/filters.py +172 -0
- laudo-0.1.0/src/laudo/filters/helpers_filters.py +166 -0
- laudo-0.1.0/src/laudo/filters/markdown_filter.py +114 -0
- laudo-0.1.0/src/laudo/globals/__init__.py +17 -0
- laudo-0.1.0/src/laudo/globals/global_functions.py +98 -0
- laudo-0.1.0/src/laudo/globals/inline_image.py +40 -0
- laudo-0.1.0/src/laudo/globals/subdoc_func.py +13 -0
- laudo-0.1.0/src/laudo/gui/__init__.py +3 -0
- laudo-0.1.0/src/laudo/gui/caption_editor.py +140 -0
- laudo-0.1.0/src/laudo/images.py +105 -0
- laudo-0.1.0/src/laudo/pdf.py +42 -0
- laudo-0.1.0/src/laudo/pre_process.py +0 -0
- laudo-0.1.0/src/laudo/py.typed +0 -0
- laudo-0.1.0/src/laudo/reference_replacer.py +104 -0
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,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
|
+
|