obsitex 0.0.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.
obsitex-0.0.0/PKG-INFO ADDED
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.1
2
+ Name: obsitex
3
+ Version: 0.0.0
4
+ Author: Rui Reis
5
+ Author-email: ruipedronetoreis12@gmail.com
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Operating System :: OS Independent
8
+ Requires-Python: >=3.8
@@ -0,0 +1,230 @@
1
+ # ObsiTex: Convert Obsidian Notes to LaTeX
2
+
3
+ ObsiTex is a Python package that automates the conversion of Obsidian Markdown files and folders into structured LaTeX documents. Designed for researchers, students, and technical writers, it ensures a seamless transition from Markdown to a fully formatted LaTeX file, preserving document structure and references.
4
+
5
+ <p align="center">
6
+ <img src="samples/images/banner.png" alt="ObsiTex" width="70%"/>
7
+ </p>
8
+
9
+ ## Why Use ObsiTex?
10
+
11
+ - Eliminates **manual LaTeX formatting** for Markdown-based notes.
12
+ - Preserves headings, lists, equations, figures, citations, and more.
13
+ - Supports **entire folders**, making it ideal for research papers and dissertations.
14
+ - Uses **Jinja2 templates**, allowing full customization of the LaTeX output.
15
+
16
+
17
+
18
+ ## Quick Start
19
+
20
+ To install ObsiTex, use pip:
21
+
22
+ ```sh
23
+ pip install obsitex
24
+ ```
25
+
26
+ Convert an Obsidian folder into a LaTeX document:
27
+
28
+ ```sh
29
+ obsitex --input "My Obsidian Folder" --main-tex output.tex
30
+ ```
31
+
32
+ Convert a single Markdown file:
33
+
34
+ ```sh
35
+ obsitex --input "My Note.md" --main-tex output.tex
36
+ ```
37
+
38
+ Use ObsiTex as a Python library:
39
+
40
+ ```python
41
+ from obsitex import ObsidianParser
42
+
43
+ parser = ObsidianParser()
44
+ parser.add_dir("My Obsidian Folder")
45
+
46
+ latex_content: str = parser.to_latex()
47
+ ```
48
+
49
+ ## Supported Elements
50
+
51
+ Most of the standard Markdown elements are supported, including:
52
+ - Equations (inline and block)
53
+ - Figures (with captions and metadata)
54
+ - Tables (with captions and metadata)
55
+ - Citations (using BibTeX references)
56
+ - Headings (up to level 6, but can be customized)
57
+ - Lists (enumerated and bulleted)
58
+ - Blockquotes
59
+ - Standard text formatting (bold, italics, code blocks, etc.)
60
+
61
+ ### Citations
62
+
63
+ This system works best if used with the Obsidian plugin [obsidian-citation-plugin](https://github.com/hans/obsidian-citation-plugin), which allows for the easy insertion of citations in markdown files. The citations must be in the format `[[@citekey]]`, where `citekey` is the key of the reference in the BibTeX file.
64
+
65
+ ### Callouts
66
+
67
+ #### Figure
68
+
69
+ Use the following syntax to create a figure in Obsidian:
70
+
71
+ ```md
72
+ > [!figure] Caption for the figure
73
+ > ![[example-image.png]]
74
+ > %%
75
+ > width: 0.5
76
+ > label: example-image
77
+ > position: H
78
+ > %%
79
+ ```
80
+
81
+ Which may be broken down as follows:
82
+ - `Caption for the figure`: The caption for the figure.
83
+ - `![[example-image.png]]`: The path to the image, if a figure is present, then the graphics folder must be provided.
84
+ - `%%`: This is a Obsidian comment, which allows for additional metadata to be added to the figure, without affecting the markdown rendering in Obsidian. If not present, default values will be used. This content must be YAML formatted.
85
+
86
+ #### Table
87
+
88
+ Use the following syntax to create a table in Obsidian:
89
+
90
+ ```md
91
+ > [!table] Random Table
92
+ > | Name | Age | City |
93
+ > |-------|-----|----------|
94
+ > | Alice | 25 | New York |
95
+ > | Bob | 30 | London |
96
+ > | Eve | 28 | Tokyo |
97
+ > %%
98
+ > prop: value
99
+ > %%
100
+ ```
101
+
102
+ This allows for the creation of tables in markdown, thus easily rendered in Obsidian, and then converted to LaTeX.
103
+
104
+ Similarly to figures, metadata can be added to the table, in order to customize the rendering of the table in LaTeX. This content must be YAML formatted.
105
+
106
+ #### Styling
107
+
108
+ These are custom blocks, thus won't have styling in Obsidian unless explictly defined in a CSS snippet. You can define the styling by following the instructions in the [Obsidian documentation](https://help.obsidian.md/Editing+and+formatting/Callouts#Customize+callouts).
109
+
110
+
111
+ ## Samples
112
+
113
+ - [Motivation Letter](#single-file---motivation-letter-for-willy-wonkas-chocolate-factory): Single file motivation letter with no citations or figures, one of the simplest use cases.
114
+ - [Sock Research Paper](#single-file---research-paper-on-socks): Single file research paper on socks with authors, affilitions, abstract, and content defined entirely in markdown.
115
+ - [MSc Dissertation on Ducks](#folder---msc-thesis-on-ducks): Folder containing a MSc thesis on ducks, with multiple markdown files under a common folder, and an `Index.md` file defining the hierarchy of the thesis.
116
+
117
+ These samples were all converted to PDF using XeLaTeX, and the output files are available in the `output` folder of each sample.
118
+
119
+ ### Single File - Motivation Letter for Willy Wonka's Chocolate Factory
120
+
121
+ Charlie Beckett is applying for a position at Willy Wonka's Chocolate Factory, and has written a motivation letter in markdown. Of course this isn't the only factory he's applying to, so he wants the letter to be easily customizable for other applications.
122
+
123
+
124
+ Unlike the other examples, this example doesn't require a BibTex file or graphics folder, as it doesn't contain any citations or figures. The LaTeX file can be generated by:
125
+
126
+ ```bash
127
+ cd samples/motivation-letter;
128
+
129
+ obsitex --input "Motivation Letter.md" \
130
+ --template template.tex \
131
+ --main-tex output/main.tex ;
132
+ ```
133
+
134
+ #### Output Files
135
+
136
+ - [main.tex](samples/motivation-letter/output/main.tex)
137
+ - [main.pdf](samples/motivation-letter/output/main.pdf)
138
+
139
+
140
+ ### Single File - Research Paper on Socks
141
+
142
+ Made up authors from the International Sock Research Institute, Textile Innovation Center, and Academy of Footwear Sciences have written a research paper on socks that was entirely developed in markdown. The authors now want to convert this document to pdf in order to submit it to a conference.
143
+
144
+ The LaTeX file and correspondings Bib file can be generated by:
145
+
146
+ ```bash
147
+ cd samples/sock-research-paper;
148
+
149
+ obsitex --input "The Evolution of Socks.md" \
150
+ --graphics ../images \
151
+ --bibtex ../shared-references.bib \
152
+ --template template.tex \
153
+ --main-tex output/main.tex \
154
+ --main-bibtex output/main.bib ;
155
+ ```
156
+
157
+ #### Output Files
158
+
159
+ - [main.tex](samples/sock-research-paper/output/main.tex)
160
+ - [main.bib](samples/sock-research-paper/output/main.bib)
161
+ - [main.pdf](samples/sock-research-paper/output/main.pdf)
162
+
163
+ ### Folder - MSc Thesis on Ducks
164
+
165
+ An unknown author has written a MSc thesis on ducks, and has organized the thesis in multiple markdown files under a common folder. The author now wants to convert this thesis to a single LaTeX file.
166
+
167
+ The folder structure is as follows:
168
+
169
+
170
+ ```
171
+ obsidian-folder
172
+ ├── Findings and Implications
173
+ │ ├── Conclusion
174
+ │ │ └── Conclusion.md
175
+ │ ├── Findings and Discussion
176
+ │ │ ├── Economic Viability.md
177
+ │ │ ├── Findings and Discussion.md
178
+ │ │ ├── Social and Cultural Implications.md
179
+ │ │ └── Urban Planning and Infrastructure Challenges.md
180
+ │ └── Findings and Implications.md
181
+ ├── Index.md
182
+ └── Introduction and Background
183
+ ├── Introduction
184
+ │ └── Introduction.md
185
+ ├── Introduction and Background.md
186
+ ├── Literature Review
187
+ │ └── Literature Review.md
188
+ └── Methodology
189
+ └── Methodology.md
190
+
191
+ 8 directories, 11 files
192
+ ```
193
+
194
+ `Index.md` defines the entry point for `obsitex`, by creating links between the different sections of the thesis, in the target order. In this example, the `Index.md` file is as follows:
195
+
196
+ ```markdown
197
+ [[Introduction and Background]]
198
+
199
+ [[Findings and Implications]]
200
+ ```
201
+
202
+ Thus, the first part will be the `Introduction and Background` part, followed by the `Findings and Implications` part. The LaTeX file and correspondings Bib file can be generated by:
203
+
204
+ ```bash
205
+ cd samples/msc-dissertation;
206
+
207
+ obsitex --input obsidian-folder \
208
+ --graphics ../images \
209
+ --bibtex ../shared-references.bib \
210
+ --template template.tex \
211
+ --main-tex output/main.tex \
212
+ --main-bibtex output/main.bib ;
213
+ ```
214
+
215
+ #### Output Files
216
+
217
+ - [main.tex](samples/msc-dissertation/output/main.tex)
218
+ - [main.bib](samples/msc-dissertation/output/main.bib)
219
+ - [main.pdf](samples/msc-dissertation/output/main.pdf)
220
+
221
+ ## Acknowledgments
222
+
223
+ This work was inspired by:
224
+ - [Obsidian Citation Plugin](https://github.com/hans/obsidian-citation-plugin) – For enabling seamless reference management within Obsidian.
225
+ - [Alejandro Daniel Noel](https://github.com/adanielnoel/Obsidian-to-latex) – His work served as an initial and valuable basis for this project.
226
+ - [dbt](https://github.com/dbt-labs/dbt-core) – For giving me the idea of using Jinja2 templates for LaTeX conversion.
227
+
228
+ ## License
229
+
230
+ This project is licensed under the MIT License.
@@ -0,0 +1 @@
1
+ from obsitex.parser import ObsidianParser
@@ -0,0 +1,102 @@
1
+ import argparse
2
+ import logging
3
+ from pathlib import Path
4
+
5
+ from obsitex import ObsidianParser
6
+ from obsitex.constants import DEFAULT_JINJA2_MAIN_TEMPLATE
7
+
8
+
9
+ def main():
10
+ parser = argparse.ArgumentParser(description="Convert Obsidian notes to LaTeX")
11
+
12
+ # Defines the inputs
13
+ parser.add_argument(
14
+ "--input",
15
+ "-i",
16
+ type=Path,
17
+ help="Path to the input file or folder containing the Obsidian notes.",
18
+ required=True,
19
+ )
20
+
21
+ parser.add_argument(
22
+ "--bibtex",
23
+ "-b",
24
+ type=Path,
25
+ help="Path to the BibTeX database file with all references.",
26
+ )
27
+ parser.add_argument(
28
+ "--graphics",
29
+ "-g",
30
+ type=Path,
31
+ help="Path to the graphics folder, where all images are assumed to be stored.",
32
+ )
33
+ parser.add_argument(
34
+ "--template",
35
+ "-t",
36
+ type=Path,
37
+ help="Path to the Jinja2 LaTeX template, won't use template if not provided.",
38
+ )
39
+
40
+ # Defines the outputs
41
+ parser.add_argument(
42
+ "--main-tex",
43
+ "-mt",
44
+ type=Path,
45
+ help="Path to the LaTeX file that will be generated, containing all compiled LaTeX.",
46
+ required=True,
47
+ )
48
+ parser.add_argument(
49
+ "--main-bibtex",
50
+ "-mb",
51
+ type=Path,
52
+ help="Path to the BibTeX file that will be generated, containing the references - only generated if citations are used.",
53
+ )
54
+
55
+ # Administrative options
56
+ parser.add_argument(
57
+ "--debug",
58
+ "-d",
59
+ action="store_true",
60
+ help="Enable debug mode, which will print additional information by enabling logging.",
61
+ )
62
+
63
+ args = parser.parse_args()
64
+
65
+ if args.debug:
66
+ logging.basicConfig(level=logging.DEBUG)
67
+
68
+ if not args.input.exists():
69
+ raise FileNotFoundError(f"Input path {args.input} does not exist.")
70
+
71
+ # Read the template if it exists
72
+ if args.template is not None and args.template.is_file():
73
+ with open(args.template, "r") as file:
74
+ template = file.read()
75
+ logging.info(f"Using template from {args.template}.")
76
+ else:
77
+ template = DEFAULT_JINJA2_MAIN_TEMPLATE
78
+ logging.info("No template provided, using default template.")
79
+
80
+ # Create the parser
81
+ parser = ObsidianParser(
82
+ graphics_folder=args.graphics,
83
+ main_template=template,
84
+ bibtex_database_path=args.bibtex,
85
+ out_bitex_path=args.main_bibtex,
86
+ )
87
+
88
+ if args.input.is_dir():
89
+ parser.add_dir(args.input)
90
+ elif args.input.is_file():
91
+ parser.add_file(args.input)
92
+ else:
93
+ raise ValueError(f"Invalid path: {args.input}")
94
+
95
+ with open(args.main_tex, "w") as file:
96
+ file.write(parser.to_latex())
97
+
98
+ print(f"Output written to {args.main_tex}")
99
+
100
+
101
+ if __name__ == "__main__":
102
+ main()
@@ -0,0 +1,41 @@
1
+ DEFAULT_JINJA2_JOB_TEMPLATE = "{{ parsed_latex_content }}"
2
+ DEFAULT_JINJA2_MAIN_TEMPLATE = """
3
+ %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
4
+ %% This file was automatically generated by obsitex.
5
+ %% A tool to convert Obsidian markdown files to LaTeX.
6
+ %% https://github.com/ruipreis/obsitex
7
+ %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
8
+
9
+ {{ parsed_latex_content }}
10
+
11
+ %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
12
+ %% End of generated file
13
+ %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
14
+ """
15
+
16
+ DEFAULT_HLEVEL_MAPPING = {
17
+ -2: "part",
18
+ -1: "chapter",
19
+ 0: "section",
20
+ 1: "subsection",
21
+ 2: "subsubsection",
22
+ 3: "paragraph",
23
+ }
24
+
25
+ # How markers are placed in parsed latex
26
+ DEFAULT_APPENDIX_MARKER = """
27
+ \\appendix
28
+ """
29
+
30
+ DEFAULT_BIBLIOGRAPHY_MARKER = """
31
+ \\bibliography{main}
32
+ """
33
+
34
+ SPECIAL_CALLOUTS = [
35
+ "[!figure]",
36
+ "[!table]",
37
+ "[!chart]",
38
+ ]
39
+
40
+ QUOTE_MARKER = "> "
41
+ CALLOUT_CONFIG_MARKER = "%%"
@@ -0,0 +1,211 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from typing import Optional, Sequence
4
+
5
+ import bibtexparser
6
+ from jinja2 import Environment
7
+
8
+ from obsitex.constants import (
9
+ DEFAULT_APPENDIX_MARKER,
10
+ DEFAULT_BIBLIOGRAPHY_MARKER,
11
+ DEFAULT_HLEVEL_MAPPING,
12
+ DEFAULT_JINJA2_JOB_TEMPLATE,
13
+ DEFAULT_JINJA2_MAIN_TEMPLATE,
14
+ )
15
+ from obsitex.parser.blocks import (
16
+ PARSEABLE_BLOCKS,
17
+ LaTeXBlock,
18
+ MarkerBlock,
19
+ Paragraph,
20
+ Section,
21
+ )
22
+ from obsitex.planner import ExecutionPlan
23
+ from obsitex.planner.jobs import AddBibliography, AddHeader, AddText, PlannedJob
24
+
25
+ # Increase logging level to bibtexparser - avoid warnings
26
+ logging.getLogger("bibtexparser").setLevel(logging.ERROR)
27
+
28
+
29
+ class ObsidianParser:
30
+ def __init__(
31
+ self,
32
+ bibtex_database_path: Optional[Path] = None,
33
+ implictly_add_bibtex: bool = True,
34
+ out_bitex_path: Optional[Path] = None,
35
+ graphics_folder: Optional[Path] = None,
36
+ job_template: str = DEFAULT_JINJA2_JOB_TEMPLATE,
37
+ main_template: str = DEFAULT_JINJA2_MAIN_TEMPLATE,
38
+ hlevel_mapping: dict = DEFAULT_HLEVEL_MAPPING,
39
+ appendix_marker: str = DEFAULT_APPENDIX_MARKER,
40
+ bibliography_marker: str = DEFAULT_BIBLIOGRAPHY_MARKER,
41
+ base_hlevel: int = 0,
42
+ ):
43
+ self.job_template = job_template
44
+ self.main_template = main_template
45
+ self.hlevel_mapping = hlevel_mapping
46
+ self.appendix_marker = appendix_marker
47
+ self.bibliography_marker = bibliography_marker
48
+ self.out_bitex_path = out_bitex_path
49
+
50
+ # Construct an execution plan, which will collect the jobs to run from
51
+ # the files and pths provided
52
+ self.execution_plan = ExecutionPlan(
53
+ bibtex_database_path=bibtex_database_path,
54
+ implictly_add_bibtex=implictly_add_bibtex,
55
+ )
56
+
57
+ # Extra arguments that should be injected when converting to latex
58
+ self.extra_args = {
59
+ "hlevel_mapping": self.hlevel_mapping,
60
+ "graphics_folder": graphics_folder,
61
+ }
62
+
63
+ # Flag to continuously check if in appendix
64
+ self.in_appendix = False
65
+
66
+ # Set of blocks that will be added to the main tex file
67
+ self.blocks: Sequence[LaTeXBlock] = []
68
+
69
+ # Keep track of the latest header level
70
+ self.base_hlevel = base_hlevel
71
+ self.latest_parsed_hlevel = base_hlevel
72
+
73
+ def add_file(self, file_path: Path, adjust_hlevel: bool = True):
74
+ # By default adding a file assumes a single file structure
75
+ if adjust_hlevel:
76
+ self.latest_parsed_hlevel = self.base_hlevel - 1
77
+
78
+ self.execution_plan.add_file(file_path)
79
+
80
+ def add_dir(self, dir_path: Path):
81
+ self.execution_plan.add_dir(dir_path)
82
+
83
+ def apply_jobs(self):
84
+ for job in self.execution_plan.iter_jobs():
85
+ self.parse_job(job)
86
+
87
+ def to_latex(self) -> str:
88
+ # Reset the parser blocks and apply
89
+ self.blocks = []
90
+ self.apply_jobs()
91
+
92
+ # Create template for job level and main
93
+ job_template = Environment().from_string(self.job_template)
94
+ main_template = Environment().from_string(self.main_template)
95
+
96
+ # Render each block onto the job template
97
+ rendered_blocks = "\n\n".join(
98
+ [
99
+ job_template.render(
100
+ parsed_latex_content=block.formatted_text(**self.extra_args),
101
+ **block.metadata,
102
+ )
103
+ for block in self.blocks
104
+ ]
105
+ )
106
+
107
+ # Render the main template with the rendered blocks
108
+ # the global variables are shared by all blocks, we use the first
109
+ # block for simplicity
110
+ if len(self.blocks) > 0:
111
+ global_configs = self.blocks[0].metadata
112
+ else:
113
+ global_configs = {}
114
+
115
+ return main_template.render(
116
+ parsed_latex_content=rendered_blocks,
117
+ **global_configs,
118
+ )
119
+
120
+ def parse_job(self, job: PlannedJob) -> str:
121
+ if not self.in_appendix:
122
+ self.in_appendix = job.is_in_appendix
123
+
124
+ # If in appendix, add the appendix marker
125
+ if self.in_appendix:
126
+ marker_block = MarkerBlock(self.appendix_marker)
127
+ marker_block.metadata = job.configs
128
+ self.blocks.append(marker_block)
129
+ logging.info("Added appendix marker to the parser.")
130
+
131
+ # Given a job, returns the corresponding latex code
132
+ if isinstance(job, AddHeader):
133
+ self.latest_parsed_hlevel = job.level
134
+ return self._parse_header(job)
135
+ elif isinstance(job, AddText):
136
+ return self._parse_text(job)
137
+ elif isinstance(job, AddBibliography):
138
+ return self._parse_bibliography(job)
139
+ else:
140
+ raise ValueError(f"Unknown job type {job}")
141
+
142
+ def _parse_header(self, job: AddHeader):
143
+ section_block = Section(job.level, job.header)
144
+ self.blocks.append(section_block)
145
+ logging.info(
146
+ f'Added header "{job.header}" with level {job.level} to the parser.'
147
+ )
148
+
149
+ def _parse_text(self, job: AddText):
150
+ lines = job.text.split("\n")
151
+ curr_i = 0
152
+ initial_block_count = len(self.blocks)
153
+
154
+ while curr_i < len(lines):
155
+ found_block = False
156
+
157
+ for block_class in PARSEABLE_BLOCKS:
158
+ block_instance = block_class.detect_block(lines, curr_i)
159
+
160
+ if block_instance is not None:
161
+ block, curr_i = block_instance
162
+
163
+ if isinstance(block, Section):
164
+ block.hlevel += self.latest_parsed_hlevel
165
+
166
+ found_block = True
167
+ block.metadata = job.configs
168
+ self.blocks.append(block)
169
+ break
170
+
171
+ if not found_block:
172
+ # If remaining, assume it's a paragraph
173
+ paragraph_block = Paragraph(lines[curr_i])
174
+ paragraph_block.metadata = job.configs
175
+ self.blocks.append(paragraph_block)
176
+
177
+ curr_i += 1
178
+
179
+ logging.info(
180
+ f"Added {len(self.blocks) - initial_block_count} blocks to the parser, total {len(self.blocks)}."
181
+ )
182
+
183
+ def _parse_bibliography(self, job: AddBibliography):
184
+ if self.out_bitex_path is None:
185
+ raise ValueError("Bibliography was added but no output path was set.")
186
+
187
+ # Select the keys to be included in the bibliography, and export
188
+ with open(job.bibtex_path, "r") as file:
189
+ bib_database = bibtexparser.load(file)
190
+
191
+ # Index the bib tex keys and verify if all are present
192
+ bib_keys = {entry["ID"]: entry for entry in bib_database.entries}
193
+ missing_keys = [key for key in job.citations if key not in bib_keys]
194
+
195
+ if len(missing_keys) > 0:
196
+ raise ValueError(
197
+ f"Missing {len(missing_keys)} keys in bibliography: {missing_keys}"
198
+ )
199
+
200
+ # Write the selected entries to a new BibTeX file
201
+ new_db = bibtexparser.bparser.BibTexParser() # Get a new BibDatabase instance
202
+ new_db.entries = [bib_keys[key] for key in job.citations]
203
+
204
+ with open(self.out_bitex_path, "w") as file:
205
+ bibtexparser.dump(new_db, file)
206
+
207
+ # Add the proper marker
208
+ marker_block = MarkerBlock(self.bibliography_marker)
209
+ marker_block.metadata = job.configs
210
+ self.blocks.append(marker_block)
211
+ logging.info("Added bibliography marker to the parser.")