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.
Files changed (25) hide show
  1. obsidian_export-0.1.0/.gitignore +10 -0
  2. obsidian_export-0.1.0/LICENSE +21 -0
  3. obsidian_export-0.1.0/PKG-INFO +184 -0
  4. obsidian_export-0.1.0/README.md +158 -0
  5. obsidian_export-0.1.0/obsidian_export/__init__.py +117 -0
  6. obsidian_export-0.1.0/obsidian_export/assets/filters/callout_boxes.lua +33 -0
  7. obsidian_export-0.1.0/obsidian_export/assets/filters/center_figures.lua +18 -0
  8. obsidian_export-0.1.0/obsidian_export/assets/filters/escape_strings.lua +30 -0
  9. obsidian_export-0.1.0/obsidian_export/assets/filters/fix_tables.lua +104 -0
  10. obsidian_export-0.1.0/obsidian_export/assets/filters/newpage_on_rule.lua +9 -0
  11. obsidian_export-0.1.0/obsidian_export/assets/filters/promote_footnotes.lua +36 -0
  12. obsidian_export-0.1.0/obsidian_export/assets/styles/default/header.tex +59 -0
  13. obsidian_export-0.1.0/obsidian_export/cli.py +141 -0
  14. obsidian_export-0.1.0/obsidian_export/config.py +159 -0
  15. obsidian_export-0.1.0/obsidian_export/defaults/default.yaml +40 -0
  16. obsidian_export-0.1.0/obsidian_export/pipeline/__init__.py +0 -0
  17. obsidian_export-0.1.0/obsidian_export/pipeline/latex_header.py +117 -0
  18. obsidian_export-0.1.0/obsidian_export/pipeline/stage1_vault.py +173 -0
  19. obsidian_export-0.1.0/obsidian_export/pipeline/stage2_preprocess.py +157 -0
  20. obsidian_export-0.1.0/obsidian_export/pipeline/stage3_mermaid.py +53 -0
  21. obsidian_export-0.1.0/obsidian_export/pipeline/stage3_svg.py +48 -0
  22. obsidian_export-0.1.0/obsidian_export/pipeline/stage4_pandoc.py +90 -0
  23. obsidian_export-0.1.0/obsidian_export/profiles.py +54 -0
  24. obsidian_export-0.1.0/obsidian_export/py.typed +0 -0
  25. obsidian_export-0.1.0/pyproject.toml +55 -0
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pixi/
7
+ pixi.lock
8
+ .mmdc/
9
+ .pytest_cache/
10
+ .hypothesis/
@@ -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