obsidian-export 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.
- obsidian_export-0.1.0/.gitignore +10 -0
- obsidian_export-0.1.0/LICENSE +21 -0
- obsidian_export-0.1.0/PKG-INFO +184 -0
- obsidian_export-0.1.0/README.md +158 -0
- obsidian_export-0.1.0/obsidian_export/__init__.py +117 -0
- obsidian_export-0.1.0/obsidian_export/assets/filters/callout_boxes.lua +33 -0
- obsidian_export-0.1.0/obsidian_export/assets/filters/center_figures.lua +18 -0
- obsidian_export-0.1.0/obsidian_export/assets/filters/escape_strings.lua +30 -0
- obsidian_export-0.1.0/obsidian_export/assets/filters/fix_tables.lua +104 -0
- obsidian_export-0.1.0/obsidian_export/assets/filters/newpage_on_rule.lua +9 -0
- obsidian_export-0.1.0/obsidian_export/assets/filters/promote_footnotes.lua +36 -0
- obsidian_export-0.1.0/obsidian_export/assets/styles/default/header.tex +59 -0
- obsidian_export-0.1.0/obsidian_export/cli.py +141 -0
- obsidian_export-0.1.0/obsidian_export/config.py +159 -0
- obsidian_export-0.1.0/obsidian_export/defaults/default.yaml +40 -0
- obsidian_export-0.1.0/obsidian_export/pipeline/__init__.py +0 -0
- obsidian_export-0.1.0/obsidian_export/pipeline/latex_header.py +117 -0
- obsidian_export-0.1.0/obsidian_export/pipeline/stage1_vault.py +173 -0
- obsidian_export-0.1.0/obsidian_export/pipeline/stage2_preprocess.py +157 -0
- obsidian_export-0.1.0/obsidian_export/pipeline/stage3_mermaid.py +53 -0
- obsidian_export-0.1.0/obsidian_export/pipeline/stage3_svg.py +48 -0
- obsidian_export-0.1.0/obsidian_export/pipeline/stage4_pandoc.py +90 -0
- obsidian_export-0.1.0/obsidian_export/profiles.py +54 -0
- obsidian_export-0.1.0/obsidian_export/py.typed +0 -0
- obsidian_export-0.1.0/pyproject.toml +55 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthias Christenson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: obsidian-export
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Convert Obsidian-flavored Markdown to PDF and DOCX via a 5-stage pipeline
|
|
5
|
+
Project-URL: Repository, https://github.com/neuralsignal/obsidian-export
|
|
6
|
+
Author: Matthias Christenson
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: converter,docx,export,markdown,obsidian,pandoc,pdf
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Office/Business
|
|
17
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Requires-Dist: click<9,>=8.0
|
|
20
|
+
Requires-Dist: pyyaml<7,>=6.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: hypothesis<7,>=6.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest-cov<6,>=5.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest<9,>=8.0; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# obsidian-export
|
|
28
|
+
|
|
29
|
+
Convert Obsidian-flavored Markdown to PDF and DOCX. Handles wikilinks, embeds, callouts, Mermaid diagrams, and frontmatter — producing clean, professional documents via a 5-stage pipeline (vault ops → preprocess → mermaid → SVG → pandoc).
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install obsidian-export
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### System Dependencies
|
|
38
|
+
|
|
39
|
+
| Dependency | Required | Purpose |
|
|
40
|
+
|-----------|----------|---------|
|
|
41
|
+
| [pandoc](https://pandoc.org/) >= 3.5 | Yes | Markdown to PDF/DOCX conversion |
|
|
42
|
+
| [tectonic](https://tectonic-typesetting.github.io/) >= 0.15 | Yes (PDF) | XeLaTeX PDF engine |
|
|
43
|
+
| [Node.js](https://nodejs.org/) >= 20 | Optional | Runtime for Mermaid CLI |
|
|
44
|
+
| [librsvg](https://wiki.gnome.org/Projects/LibRsvg) | Optional | SVG to PDF conversion |
|
|
45
|
+
|
|
46
|
+
Check your setup:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
obsidian-export doctor
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick Start
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Convert with default settings
|
|
56
|
+
obsidian-export convert --input my_note.md --format pdf --output my_note.pdf
|
|
57
|
+
|
|
58
|
+
# Convert to DOCX
|
|
59
|
+
obsidian-export convert --input my_note.md --format docx --output my_note.docx
|
|
60
|
+
|
|
61
|
+
# Use a custom profile
|
|
62
|
+
obsidian-export convert --input my_note.md --format pdf --output my_note.pdf --profile my_brand
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Profile Management
|
|
66
|
+
|
|
67
|
+
Profiles are YAML config files stored in `~/.obsidian-export/profiles/`.
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# Initialize directory structure and default profile
|
|
71
|
+
obsidian-export init
|
|
72
|
+
|
|
73
|
+
# Create a new profile (starts from defaults)
|
|
74
|
+
obsidian-export profile create my_brand
|
|
75
|
+
|
|
76
|
+
# Create from existing YAML
|
|
77
|
+
obsidian-export profile create my_brand --from existing_config.yaml
|
|
78
|
+
|
|
79
|
+
# List profiles
|
|
80
|
+
obsidian-export profile list
|
|
81
|
+
|
|
82
|
+
# Show profile contents
|
|
83
|
+
obsidian-export profile show my_brand
|
|
84
|
+
|
|
85
|
+
# Delete a profile
|
|
86
|
+
obsidian-export profile delete my_brand --yes
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Custom Styles
|
|
90
|
+
|
|
91
|
+
Styles are LaTeX header templates. Place custom styles in `~/.obsidian-export/styles/<name>/header.tex`.
|
|
92
|
+
|
|
93
|
+
Style resolution order:
|
|
94
|
+
1. `style_dir` field in config (explicit path)
|
|
95
|
+
2. Built-in styles (`default`)
|
|
96
|
+
3. User styles in `~/.obsidian-export/styles/<name>/`
|
|
97
|
+
4. Treat style name as a filesystem path
|
|
98
|
+
|
|
99
|
+
## Configuration
|
|
100
|
+
|
|
101
|
+
A config YAML can override any subset of defaults. Only include fields you want to change:
|
|
102
|
+
|
|
103
|
+
```yaml
|
|
104
|
+
# Minimal override — everything else uses defaults
|
|
105
|
+
style:
|
|
106
|
+
fontsize: "12pt"
|
|
107
|
+
mainfont: "Georgia"
|
|
108
|
+
line_spacing: 1.5
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Full Config Reference
|
|
112
|
+
|
|
113
|
+
```yaml
|
|
114
|
+
mermaid:
|
|
115
|
+
mmdc_bin: "mmdc" # Path to Mermaid CLI binary
|
|
116
|
+
scale: 3 # PNG render scale
|
|
117
|
+
|
|
118
|
+
obsidian:
|
|
119
|
+
wikilink_strategy: "text" # How to handle [[wikilinks]]
|
|
120
|
+
url_strategy: "footnote_long" # bare URL handling: keep|footnote_long|footnote_all|strip
|
|
121
|
+
url_length_threshold: 60 # URL length for footnote_long strategy
|
|
122
|
+
|
|
123
|
+
pandoc:
|
|
124
|
+
from_format: "gfm-tex_math_dollars" # Pandoc input format
|
|
125
|
+
|
|
126
|
+
style:
|
|
127
|
+
name: "default" # Style name (resolves to header.tex template)
|
|
128
|
+
geometry: "a4paper,margin=25mm" # Page geometry
|
|
129
|
+
fontsize: "10pt" # Base font size
|
|
130
|
+
mainfont: "" # Main font (XeLaTeX)
|
|
131
|
+
sansfont: "" # Sans font
|
|
132
|
+
monofont: "" # Mono font
|
|
133
|
+
linkcolor: "NavyBlue" # Internal link color
|
|
134
|
+
urlcolor: "NavyBlue" # URL color
|
|
135
|
+
line_spacing: 1.0 # Line spacing multiplier
|
|
136
|
+
table_fontsize: "small" # Font size in tables
|
|
137
|
+
image_max_height_ratio: 0.40 # Max image height as fraction of page
|
|
138
|
+
url_footnote_threshold: 60 # URL length threshold for footnoting
|
|
139
|
+
header_left: "" # Left header (supports {doc_title}, {logo_path})
|
|
140
|
+
header_right: "" # Right header
|
|
141
|
+
footer_left: "" # Left footer
|
|
142
|
+
footer_center: "\\thepage" # Center footer
|
|
143
|
+
footer_right: "" # Right footer
|
|
144
|
+
logo: "" # Logo filename (relative to style dir)
|
|
145
|
+
style_dir: "" # Explicit style directory path
|
|
146
|
+
callout_colors:
|
|
147
|
+
note: [219, 234, 254]
|
|
148
|
+
tip: [220, 252, 231]
|
|
149
|
+
warning: [254, 243, 199]
|
|
150
|
+
danger: [254, 226, 226]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Python API
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from pathlib import Path
|
|
157
|
+
from obsidian_export import run
|
|
158
|
+
from obsidian_export.config import default_config, load_config
|
|
159
|
+
|
|
160
|
+
# Using defaults
|
|
161
|
+
config = default_config()
|
|
162
|
+
run(Path("my_note.md"), Path("output.pdf"), "pdf", config)
|
|
163
|
+
|
|
164
|
+
# Using a config file
|
|
165
|
+
config = load_config(Path("my_config.yaml"))
|
|
166
|
+
run(Path("my_note.md"), Path("output.pdf"), "pdf", config)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## What It Does
|
|
170
|
+
|
|
171
|
+
| Obsidian Syntax | Result |
|
|
172
|
+
|----------------|--------|
|
|
173
|
+
| `![[embed]]` | Resolved inline (content inlined) |
|
|
174
|
+
| `[[Entity\|Display]]` | Replaced with `Display` |
|
|
175
|
+
| `[[Entity]]` | Replaced with `Entity` |
|
|
176
|
+
| `> [!note]` callouts | Colored boxes (PDF) or blockquotes (DOCX) |
|
|
177
|
+
| `` ```mermaid `` | Rendered to PNG |
|
|
178
|
+
| `## Relations` section | Removed |
|
|
179
|
+
| YAML frontmatter | Title extracted, tags → keywords, rest removed |
|
|
180
|
+
| `$25/user` | Safe literal dollar sign |
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# obsidian-export
|
|
2
|
+
|
|
3
|
+
Convert Obsidian-flavored Markdown to PDF and DOCX. Handles wikilinks, embeds, callouts, Mermaid diagrams, and frontmatter — producing clean, professional documents via a 5-stage pipeline (vault ops → preprocess → mermaid → SVG → pandoc).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install obsidian-export
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
### System Dependencies
|
|
12
|
+
|
|
13
|
+
| Dependency | Required | Purpose |
|
|
14
|
+
|-----------|----------|---------|
|
|
15
|
+
| [pandoc](https://pandoc.org/) >= 3.5 | Yes | Markdown to PDF/DOCX conversion |
|
|
16
|
+
| [tectonic](https://tectonic-typesetting.github.io/) >= 0.15 | Yes (PDF) | XeLaTeX PDF engine |
|
|
17
|
+
| [Node.js](https://nodejs.org/) >= 20 | Optional | Runtime for Mermaid CLI |
|
|
18
|
+
| [librsvg](https://wiki.gnome.org/Projects/LibRsvg) | Optional | SVG to PDF conversion |
|
|
19
|
+
|
|
20
|
+
Check your setup:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
obsidian-export doctor
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# Convert with default settings
|
|
30
|
+
obsidian-export convert --input my_note.md --format pdf --output my_note.pdf
|
|
31
|
+
|
|
32
|
+
# Convert to DOCX
|
|
33
|
+
obsidian-export convert --input my_note.md --format docx --output my_note.docx
|
|
34
|
+
|
|
35
|
+
# Use a custom profile
|
|
36
|
+
obsidian-export convert --input my_note.md --format pdf --output my_note.pdf --profile my_brand
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Profile Management
|
|
40
|
+
|
|
41
|
+
Profiles are YAML config files stored in `~/.obsidian-export/profiles/`.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Initialize directory structure and default profile
|
|
45
|
+
obsidian-export init
|
|
46
|
+
|
|
47
|
+
# Create a new profile (starts from defaults)
|
|
48
|
+
obsidian-export profile create my_brand
|
|
49
|
+
|
|
50
|
+
# Create from existing YAML
|
|
51
|
+
obsidian-export profile create my_brand --from existing_config.yaml
|
|
52
|
+
|
|
53
|
+
# List profiles
|
|
54
|
+
obsidian-export profile list
|
|
55
|
+
|
|
56
|
+
# Show profile contents
|
|
57
|
+
obsidian-export profile show my_brand
|
|
58
|
+
|
|
59
|
+
# Delete a profile
|
|
60
|
+
obsidian-export profile delete my_brand --yes
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Custom Styles
|
|
64
|
+
|
|
65
|
+
Styles are LaTeX header templates. Place custom styles in `~/.obsidian-export/styles/<name>/header.tex`.
|
|
66
|
+
|
|
67
|
+
Style resolution order:
|
|
68
|
+
1. `style_dir` field in config (explicit path)
|
|
69
|
+
2. Built-in styles (`default`)
|
|
70
|
+
3. User styles in `~/.obsidian-export/styles/<name>/`
|
|
71
|
+
4. Treat style name as a filesystem path
|
|
72
|
+
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
A config YAML can override any subset of defaults. Only include fields you want to change:
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
# Minimal override — everything else uses defaults
|
|
79
|
+
style:
|
|
80
|
+
fontsize: "12pt"
|
|
81
|
+
mainfont: "Georgia"
|
|
82
|
+
line_spacing: 1.5
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Full Config Reference
|
|
86
|
+
|
|
87
|
+
```yaml
|
|
88
|
+
mermaid:
|
|
89
|
+
mmdc_bin: "mmdc" # Path to Mermaid CLI binary
|
|
90
|
+
scale: 3 # PNG render scale
|
|
91
|
+
|
|
92
|
+
obsidian:
|
|
93
|
+
wikilink_strategy: "text" # How to handle [[wikilinks]]
|
|
94
|
+
url_strategy: "footnote_long" # bare URL handling: keep|footnote_long|footnote_all|strip
|
|
95
|
+
url_length_threshold: 60 # URL length for footnote_long strategy
|
|
96
|
+
|
|
97
|
+
pandoc:
|
|
98
|
+
from_format: "gfm-tex_math_dollars" # Pandoc input format
|
|
99
|
+
|
|
100
|
+
style:
|
|
101
|
+
name: "default" # Style name (resolves to header.tex template)
|
|
102
|
+
geometry: "a4paper,margin=25mm" # Page geometry
|
|
103
|
+
fontsize: "10pt" # Base font size
|
|
104
|
+
mainfont: "" # Main font (XeLaTeX)
|
|
105
|
+
sansfont: "" # Sans font
|
|
106
|
+
monofont: "" # Mono font
|
|
107
|
+
linkcolor: "NavyBlue" # Internal link color
|
|
108
|
+
urlcolor: "NavyBlue" # URL color
|
|
109
|
+
line_spacing: 1.0 # Line spacing multiplier
|
|
110
|
+
table_fontsize: "small" # Font size in tables
|
|
111
|
+
image_max_height_ratio: 0.40 # Max image height as fraction of page
|
|
112
|
+
url_footnote_threshold: 60 # URL length threshold for footnoting
|
|
113
|
+
header_left: "" # Left header (supports {doc_title}, {logo_path})
|
|
114
|
+
header_right: "" # Right header
|
|
115
|
+
footer_left: "" # Left footer
|
|
116
|
+
footer_center: "\\thepage" # Center footer
|
|
117
|
+
footer_right: "" # Right footer
|
|
118
|
+
logo: "" # Logo filename (relative to style dir)
|
|
119
|
+
style_dir: "" # Explicit style directory path
|
|
120
|
+
callout_colors:
|
|
121
|
+
note: [219, 234, 254]
|
|
122
|
+
tip: [220, 252, 231]
|
|
123
|
+
warning: [254, 243, 199]
|
|
124
|
+
danger: [254, 226, 226]
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Python API
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from pathlib import Path
|
|
131
|
+
from obsidian_export import run
|
|
132
|
+
from obsidian_export.config import default_config, load_config
|
|
133
|
+
|
|
134
|
+
# Using defaults
|
|
135
|
+
config = default_config()
|
|
136
|
+
run(Path("my_note.md"), Path("output.pdf"), "pdf", config)
|
|
137
|
+
|
|
138
|
+
# Using a config file
|
|
139
|
+
config = load_config(Path("my_config.yaml"))
|
|
140
|
+
run(Path("my_note.md"), Path("output.pdf"), "pdf", config)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## What It Does
|
|
144
|
+
|
|
145
|
+
| Obsidian Syntax | Result |
|
|
146
|
+
|----------------|--------|
|
|
147
|
+
| `![[embed]]` | Resolved inline (content inlined) |
|
|
148
|
+
| `[[Entity\|Display]]` | Replaced with `Display` |
|
|
149
|
+
| `[[Entity]]` | Replaced with `Entity` |
|
|
150
|
+
| `> [!note]` callouts | Colored boxes (PDF) or blockquotes (DOCX) |
|
|
151
|
+
| `` ```mermaid `` | Rendered to PNG |
|
|
152
|
+
| `## Relations` section | Removed |
|
|
153
|
+
| YAML frontmatter | Title extracted, tags → keywords, rest removed |
|
|
154
|
+
| `$25/user` | Safe literal dollar sign |
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""obsidian-export: Obsidian -> PDF/DOCX pipeline.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
run(input_path, output_path, output_format, config) -> None
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from obsidian_export.config import ConvertConfig, StyleConfig
|
|
11
|
+
from obsidian_export.pipeline.latex_header import render_header
|
|
12
|
+
from obsidian_export.pipeline.stage1_vault import (
|
|
13
|
+
clean_frontmatter,
|
|
14
|
+
parse_frontmatter,
|
|
15
|
+
resolve_embeds,
|
|
16
|
+
strip_leading_title,
|
|
17
|
+
strip_obsidian_syntax,
|
|
18
|
+
)
|
|
19
|
+
from obsidian_export.pipeline.stage2_preprocess import preprocess
|
|
20
|
+
from obsidian_export.pipeline.stage3_mermaid import render_mermaid_blocks
|
|
21
|
+
from obsidian_export.pipeline.stage3_svg import convert_svg_images
|
|
22
|
+
from obsidian_export.pipeline.stage4_pandoc import convert_to_docx, convert_to_pdf
|
|
23
|
+
from obsidian_export.profiles import USER_STYLES_DIR
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _resolve_style_dir(style: StyleConfig) -> Path:
|
|
27
|
+
"""Resolve style directory.
|
|
28
|
+
|
|
29
|
+
Resolution order:
|
|
30
|
+
1. style.style_dir if set (absolute or relative path)
|
|
31
|
+
2. Built-in assets/styles/<name>/
|
|
32
|
+
3. ~/.obsidian-export/styles/<name>/
|
|
33
|
+
4. Treat style.name as an absolute/relative path
|
|
34
|
+
"""
|
|
35
|
+
# Explicit style_dir override
|
|
36
|
+
if style.style_dir:
|
|
37
|
+
candidate = Path(style.style_dir)
|
|
38
|
+
if candidate.is_dir():
|
|
39
|
+
return candidate
|
|
40
|
+
raise FileNotFoundError(f"Style dir not found: {style.style_dir!r}")
|
|
41
|
+
|
|
42
|
+
name = style.name
|
|
43
|
+
|
|
44
|
+
# Built-in styles
|
|
45
|
+
builtin = Path(__file__).parent / "assets" / "styles" / name
|
|
46
|
+
if builtin.is_dir():
|
|
47
|
+
return builtin
|
|
48
|
+
|
|
49
|
+
# User styles
|
|
50
|
+
user = USER_STYLES_DIR / name
|
|
51
|
+
if user.is_dir():
|
|
52
|
+
return user
|
|
53
|
+
|
|
54
|
+
# Treat as path
|
|
55
|
+
candidate = Path(name)
|
|
56
|
+
if candidate.is_dir():
|
|
57
|
+
return candidate
|
|
58
|
+
|
|
59
|
+
raise FileNotFoundError(f"Style not found: {name!r} (checked {builtin}, {user}, and {candidate})")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def run(
|
|
63
|
+
input_path: Path,
|
|
64
|
+
output_path: Path,
|
|
65
|
+
output_format: str,
|
|
66
|
+
config: ConvertConfig,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Full pipeline: Stage 1 -> Stage 2 -> Stage 3 -> Stage 4.
|
|
69
|
+
|
|
70
|
+
input_path: absolute path to source .md file
|
|
71
|
+
output_path: absolute path for output file (parent dirs created automatically)
|
|
72
|
+
output_format: "pdf" or "docx"
|
|
73
|
+
config: fully-populated ConvertConfig (no defaults)
|
|
74
|
+
"""
|
|
75
|
+
if output_format not in ("pdf", "docx"):
|
|
76
|
+
raise ValueError(f"Unsupported output format: {output_format!r}. Use 'pdf' or 'docx'.")
|
|
77
|
+
|
|
78
|
+
source = input_path.read_text(encoding="utf-8")
|
|
79
|
+
|
|
80
|
+
# Stage 1: Vault operations
|
|
81
|
+
fm, body = parse_frontmatter(source)
|
|
82
|
+
fm = clean_frontmatter(fm)
|
|
83
|
+
# .get() intentional: title is genuinely optional in frontmatter;
|
|
84
|
+
# many knowledge notes omit it, so we fall back to the filename stem.
|
|
85
|
+
title = str(fm.get("title", input_path.stem))
|
|
86
|
+
body = strip_leading_title(body, title)
|
|
87
|
+
vault_root = input_path.parent
|
|
88
|
+
body = resolve_embeds(body, vault_root, input_path)
|
|
89
|
+
body = strip_obsidian_syntax(body)
|
|
90
|
+
|
|
91
|
+
# Stage 2: Text-level pre-processing
|
|
92
|
+
body = preprocess(body, config.obsidian)
|
|
93
|
+
|
|
94
|
+
# Stage 3: Mermaid diagram rendering
|
|
95
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
96
|
+
body = render_mermaid_blocks(body, config.mermaid, Path(tmpdir))
|
|
97
|
+
|
|
98
|
+
# Stage 3b: SVG -> PDF conversion (PDF output only)
|
|
99
|
+
if output_format == "pdf":
|
|
100
|
+
body = convert_svg_images(body, Path(tmpdir))
|
|
101
|
+
|
|
102
|
+
# Stage 4: Pandoc conversion
|
|
103
|
+
if output_format == "pdf":
|
|
104
|
+
style_dir = _resolve_style_dir(config.style)
|
|
105
|
+
filters_dir = Path(__file__).parent / "assets" / "filters"
|
|
106
|
+
rendered_header = render_header(config.style, style_dir / "header.tex", title)
|
|
107
|
+
convert_to_pdf(
|
|
108
|
+
body,
|
|
109
|
+
title,
|
|
110
|
+
config.pandoc,
|
|
111
|
+
config.style,
|
|
112
|
+
rendered_header,
|
|
113
|
+
filters_dir,
|
|
114
|
+
output_path,
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
convert_to_docx(body, title, config.pandoc, output_path)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-- callout_boxes.lua
|
|
2
|
+
-- Convert Pandoc fenced divs (.note, .tip, .warning, .danger, etc.) to
|
|
3
|
+
-- tcolorbox LaTeX environments. Only applies when outputting LaTeX (PDF).
|
|
4
|
+
if FORMAT ~= "latex" then return {} end
|
|
5
|
+
|
|
6
|
+
local callout_colors = {
|
|
7
|
+
note = "noteblue",
|
|
8
|
+
info = "noteblue",
|
|
9
|
+
tip = "tipgreen",
|
|
10
|
+
success = "tipgreen",
|
|
11
|
+
warning = "warnyellow",
|
|
12
|
+
caution = "warnyellow",
|
|
13
|
+
danger = "dangerred",
|
|
14
|
+
important = "dangerred",
|
|
15
|
+
error = "dangerred",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function Div(el)
|
|
19
|
+
for class, color in pairs(callout_colors) do
|
|
20
|
+
if el.classes:includes(class) then
|
|
21
|
+
local title = el.attributes["title"] or (class:sub(1,1):upper() .. class:sub(2))
|
|
22
|
+
-- Render inner content to LaTeX
|
|
23
|
+
local inner_doc = pandoc.Pandoc(el.content)
|
|
24
|
+
local inner_latex = pandoc.write(inner_doc, "latex")
|
|
25
|
+
local latex = string.format(
|
|
26
|
+
"\\begin{calloutbox}{%s}{%s}\n%s\\end{calloutbox}\n",
|
|
27
|
+
color, title, inner_latex
|
|
28
|
+
)
|
|
29
|
+
return pandoc.RawBlock("latex", latex)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
return el
|
|
33
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- center_figures.lua
|
|
2
|
+
-- Promote standalone images (lone image in a paragraph) to centered
|
|
3
|
+
-- LaTeX figure environments. Needed because GFM (--from=gfm) treats
|
|
4
|
+
-- all images as inline; pandoc never emits \begin{figure}, so
|
|
5
|
+
-- \AtBeginEnvironment{figure}{\centering} has no effect.
|
|
6
|
+
if FORMAT ~= "latex" then return {} end
|
|
7
|
+
|
|
8
|
+
function Para(el)
|
|
9
|
+
if #el.content == 1 and el.content[1].t == "Image" then
|
|
10
|
+
local img = el.content[1]
|
|
11
|
+
return pandoc.RawBlock("latex",
|
|
12
|
+
"\\begin{figure}[H]\n" ..
|
|
13
|
+
"\\centering\n" ..
|
|
14
|
+
"\\includegraphics[width=\\maxwidth,height=\\maxheight,keepaspectratio]{" .. img.src .. "}\n" ..
|
|
15
|
+
"\\end{figure}"
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
-- escape_strings.lua
|
|
2
|
+
-- Escape LaTeX-special characters (^ and ~) in plain text Str nodes.
|
|
3
|
+
-- When ^ or ~ are found, converts the entire Str to a RawInline(latex) with
|
|
4
|
+
-- all LaTeX specials properly escaped (including $, &, %, #, _, {, }, \).
|
|
5
|
+
-- Only applies when outputting LaTeX (PDF). DOCX and other formats: no-op.
|
|
6
|
+
if FORMAT ~= "latex" then return {} end
|
|
7
|
+
|
|
8
|
+
local function escape_latex_specials(s)
|
|
9
|
+
-- Order matters: escape backslash first to avoid double-escaping
|
|
10
|
+
s = s:gsub("\\", "\\textbackslash{}")
|
|
11
|
+
s = s:gsub("%%", "\\%%")
|
|
12
|
+
s = s:gsub("%$", "\\$")
|
|
13
|
+
s = s:gsub("&", "\\&")
|
|
14
|
+
s = s:gsub("#", "\\#")
|
|
15
|
+
s = s:gsub("_", "\\_")
|
|
16
|
+
s = s:gsub("{", "\\{")
|
|
17
|
+
s = s:gsub("}", "\\}")
|
|
18
|
+
s = s:gsub("%^", "\\^{}")
|
|
19
|
+
s = s:gsub("~", "\\textasciitilde{}")
|
|
20
|
+
return s
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
function Str(el)
|
|
24
|
+
local orig = el.text
|
|
25
|
+
-- Only intercept when ^ or ~ are present; otherwise let pandoc handle normally
|
|
26
|
+
if not (orig:find("%^") or orig:find("~")) then
|
|
27
|
+
return el
|
|
28
|
+
end
|
|
29
|
+
return pandoc.RawInline("latex", escape_latex_specials(orig))
|
|
30
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
-- fix_tables.lua
|
|
2
|
+
-- Replace Table AST nodes with xltabular RawBlock for PDF output.
|
|
3
|
+
-- Uses equal-width X columns (from tabularx) with page-breaking (from longtable).
|
|
4
|
+
-- Header rows repeat on continuation pages via \endhead.
|
|
5
|
+
if FORMAT ~= "latex" then return {} end
|
|
6
|
+
|
|
7
|
+
local meta_table_fontsize = nil
|
|
8
|
+
|
|
9
|
+
function Meta(meta)
|
|
10
|
+
if meta.table_fontsize then
|
|
11
|
+
meta_table_fontsize = pandoc.utils.stringify(meta.table_fontsize)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
local function cell_to_latex(blocks)
|
|
16
|
+
if #blocks == 0 then return "" end
|
|
17
|
+
local doc = pandoc.Pandoc(blocks)
|
|
18
|
+
local result = pandoc.write(doc, "latex")
|
|
19
|
+
-- Strip document boilerplate, keep body content only
|
|
20
|
+
result = result:match("\\begin{document}%s*(.-)%s*\\end{document}") or result
|
|
21
|
+
-- Assign to local to discard gsub's second return value (substitution count)
|
|
22
|
+
local trimmed = result:gsub("^%s+", ""):gsub("%s+$", "")
|
|
23
|
+
return trimmed
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
local function col_spec(align)
|
|
27
|
+
if align == pandoc.AlignRight then
|
|
28
|
+
return ">{\\raggedleft\\arraybackslash}X"
|
|
29
|
+
elseif align == pandoc.AlignCenter then
|
|
30
|
+
return ">{\\centering\\arraybackslash}X"
|
|
31
|
+
else
|
|
32
|
+
return ">{\\raggedright\\arraybackslash}X"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
function Table(el)
|
|
37
|
+
local n = #el.colspecs
|
|
38
|
+
if n == 0 then return el end
|
|
39
|
+
|
|
40
|
+
local col_specs = {}
|
|
41
|
+
for i = 1, n do
|
|
42
|
+
col_specs[i] = col_spec(el.colspecs[i][1])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
local fontsize_cmd = "\\" .. (meta_table_fontsize or "small")
|
|
46
|
+
local lines = {}
|
|
47
|
+
table.insert(lines, "{" .. fontsize_cmd)
|
|
48
|
+
table.insert(lines, "\\begin{xltabular}{\\linewidth}{" .. table.concat(col_specs) .. "}")
|
|
49
|
+
|
|
50
|
+
-- Header rows (bold) with longtable continuation headers
|
|
51
|
+
if el.head and el.head.rows and #el.head.rows > 0 then
|
|
52
|
+
-- First page header: toprule + header + midrule
|
|
53
|
+
table.insert(lines, "\\toprule")
|
|
54
|
+
local header_lines = {}
|
|
55
|
+
for _, row in ipairs(el.head.rows) do
|
|
56
|
+
local cells = {}
|
|
57
|
+
for _, cell in ipairs(row.cells) do
|
|
58
|
+
-- pandoc 3.x uses cell.content (not cell.contents)
|
|
59
|
+
local content = cell_to_latex(cell.content or cell.contents or {})
|
|
60
|
+
if content ~= "" then
|
|
61
|
+
table.insert(cells, "\\textbf{" .. content .. "}")
|
|
62
|
+
else
|
|
63
|
+
table.insert(cells, "")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
table.insert(header_lines, table.concat(cells, " & ") .. " \\\\")
|
|
67
|
+
end
|
|
68
|
+
for _, hl in ipairs(header_lines) do
|
|
69
|
+
table.insert(lines, hl)
|
|
70
|
+
end
|
|
71
|
+
table.insert(lines, "\\midrule")
|
|
72
|
+
table.insert(lines, "\\endfirsthead")
|
|
73
|
+
|
|
74
|
+
-- Continuation page header: midrule + header + midrule
|
|
75
|
+
table.insert(lines, "\\midrule")
|
|
76
|
+
for _, hl in ipairs(header_lines) do
|
|
77
|
+
table.insert(lines, hl)
|
|
78
|
+
end
|
|
79
|
+
table.insert(lines, "\\midrule")
|
|
80
|
+
table.insert(lines, "\\endhead")
|
|
81
|
+
else
|
|
82
|
+
table.insert(lines, "\\toprule")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
-- Body rows
|
|
86
|
+
for _, body in ipairs(el.bodies) do
|
|
87
|
+
for _, row in ipairs(body.body) do
|
|
88
|
+
local cells = {}
|
|
89
|
+
for _, cell in ipairs(row.cells) do
|
|
90
|
+
local content = cell_to_latex(cell.content or cell.contents or {})
|
|
91
|
+
table.insert(cells, content)
|
|
92
|
+
end
|
|
93
|
+
table.insert(lines, table.concat(cells, " & ") .. " \\\\")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
table.insert(lines, "\\bottomrule")
|
|
98
|
+
table.insert(lines, "\\end{xltabular}")
|
|
99
|
+
table.insert(lines, "}")
|
|
100
|
+
|
|
101
|
+
return pandoc.RawBlock("latex", table.concat(lines, "\n"))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
return {{Meta = Meta}, {Table = Table}}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- newpage_on_rule.lua
|
|
2
|
+
-- Convert HorizontalRule to \newpage in LaTeX/PDF output.
|
|
3
|
+
-- In Obsidian notes, --- is commonly used as a section divider
|
|
4
|
+
-- where a page break is the intended PDF behaviour.
|
|
5
|
+
if FORMAT ~= "latex" then return {} end
|
|
6
|
+
|
|
7
|
+
function HorizontalRule()
|
|
8
|
+
return pandoc.RawBlock("latex", "\\newpage")
|
|
9
|
+
end
|