From 3d3fc1b964beac07875f607f9255ff0a4a5584cc Mon Sep 17 00:00:00 2001 From: Artiom Gutan Date: Mon, 1 Dec 2025 16:02:17 +0200 Subject: [PATCH 01/18] add gitignore --- .gitignore | 186 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80524b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,186 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Venv stuff +bin/ +include/ +pyvenv.cfg + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +lib64 +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +out/ + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +.vscode/ + +# PyPI configuration file +.pypirc \ No newline at end of file From 48599b5adc181ee96c9d89462b114e58cfd640a2 Mon Sep 17 00:00:00 2001 From: Artiom Gutan Date: Mon, 1 Dec 2025 16:05:27 +0200 Subject: [PATCH 02/18] setup for beautiful soup --- requirements.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..55c7ee1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +beautifulsoup4==4.14.3 +Markdown==3.10 +pip==25.3 From 407fe3a6bf8fd89841d52ca94e6bd55f1da4c4c3 Mon Sep 17 00:00:00 2001 From: Artiom Gutan Date: Mon, 1 Dec 2025 16:06:17 +0200 Subject: [PATCH 03/18] fix sys.exit call, add recursive directory creation for epub output --- mark2epub.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mark2epub.py b/mark2epub.py index 851ef00..aaf7df1 100755 --- a/mark2epub.py +++ b/mark2epub.py @@ -233,7 +233,7 @@ def get_chapter_XML(md_filename,css_filenames): if __name__ == "__main__": if len(sys.argv[1:])<2: print("\nUsage:\n python md2epub.py ") - exit(1) + sys.exit(1) work_dir = sys.argv[1] @@ -260,6 +260,8 @@ def get_chapter_XML(md_filename,css_filenames): ###################################################### ## Now creating the ePUB book + os.makedirs(os.path.dirname(output_path), exist_ok=True) + with zipfile.ZipFile(output_path, "w" ) as myZipFile: ## First, write the mimetype From 2b8f7d47d492a4e61b11856ec094d288f46fdd54 Mon Sep 17 00:00:00 2001 From: Artiom Gutan Date: Mon, 1 Dec 2025 16:09:52 +0200 Subject: [PATCH 04/18] build script for easy one-file usage --- build.sh | 5 +++++ 1 file changed, 5 insertions(+) create mode 100755 build.sh diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..f129e35 --- /dev/null +++ b/build.sh @@ -0,0 +1,5 @@ +#! /usr/bin/bash + +source ./bin/activate +pip install -r requirements.txt +pyinstaller --clean -F -y -n "mark2epub" mark2epub.py \ No newline at end of file From 7d770fce9f3fe19822a1e9b49ad44ef05d8b88ce Mon Sep 17 00:00:00 2001 From: Artiom Gutan Date: Mon, 1 Dec 2025 17:55:50 +0200 Subject: [PATCH 05/18] custom class names for markdown elements feature --- epub_md/chapter1.md | 28 ++-- epub_md/css/general.css | 17 +-- mark2epub.py | 309 ++++++++++++++++++++++++---------------- 3 files changed, 210 insertions(+), 144 deletions(-) diff --git a/epub_md/chapter1.md b/epub_md/chapter1.md index bf14bb2..308e5fe 100755 --- a/epub_md/chapter1.md +++ b/epub_md/chapter1.md @@ -1,8 +1,8 @@ -# Chapter 1 +# Chapter 1 {example of multiple class names} -This is some simple text in Markdown. +This is some simple text in Markdown. {WORKS ON EVERY TAG ALLEGEDLY THO} -## Section 1 +## Section 1 {Allows only whitelisted characters google it} Denique Antiochensis **ordinis vertices sub uno elogio iussit occidi** ideo efferatus, quod ei celebrari vilitatem intempestivam urgenti, cum inpenderet inopia, @@ -11,7 +11,7 @@ Honoratus fixa constantia restitisset. ## Section 2 -Et interdum acciderat, *ut siquid in penetrali secreto nullo citerioris vitae* +Et interdum acciderat, _ut siquid in penetrali secreto nullo citerioris vitae_ ministro praesente paterfamilias uxori susurrasset in aurem, velut Amphiarao referente aut Marcio, quondam vatibus inclitis, postridie disceret imperator. ideoque etiam parietes arcanorum soli conscii timebantur. @@ -20,14 +20,14 @@ ideoque etiam parietes arcanorum soli conscii timebantur. Saepissime igitur mihi de amicitia cogitanti maxime illud considerandum videri solet, utrum propter imbecillitatem atque inopiam desiderata sit amicitia, -ut dandis recipiendisque meritis +ut dandis recipiendisque meritis -* quod quisque minus per se ipse posset, - * id acciperet -* ab alio vicissimque redderet, - * an esset hoc quidem proprium amicitiae, - *sed antiquior et - * pulchrior et magis a natura ipsa profecta alia causa. +- quod quisque minus per se ipse posset, + - id acciperet +- ab alio vicissimque redderet, + - an esset hoc quidem proprium amicitiae, + \*sed antiquior et + - pulchrior et magis a natura ipsa profecta alia causa. ### Subsection @@ -35,6 +35,6 @@ Amor enim, ex quo amicitia nominata est, princeps est ad benevolentiam coniungendam. 1. Nam utilitates quidem etiam ab iis percipiuntur saepe - 1. qui simulatione amicitiae coluntur - 2. et observantur temporis causa, -2. in amicitia autem nihil fictum est, nihil simulatum et, quidquid est, id est verum et voluntarium. \ No newline at end of file + 1. qui simulatione amicitiae coluntur + 2. et observantur temporis causa, +2. in amicitia autem nihil fictum est, nihil simulatum et, quidquid est, id est verum et voluntarium. diff --git a/epub_md/css/general.css b/epub_md/css/general.css index d3f2200..07688a7 100755 --- a/epub_md/css/general.css +++ b/epub_md/css/general.css @@ -1,26 +1,27 @@ body { - font-size: 0.8em; + font-size: 0.8em; } - img { - max-width: 100%; - height: auto; + max-width: 100%; + height: auto; } - table { border-collapse: collapse; width: 100%; font-size: 0.5em; } -td,th { +td, +th { border: 1px solid #ddd; padding: 8px; } -tr:nth-child(even){background-color: #f2f2f2;} +tr:nth-child(even) { + background-color: #f2f2f2; +} th { padding-top: 12px; @@ -29,4 +30,4 @@ th { background-color: #444444; color: white; font-size: 0.8em; -} \ No newline at end of file +} diff --git a/mark2epub.py b/mark2epub.py index aaf7df1..5ceddea 100755 --- a/mark2epub.py +++ b/mark2epub.py @@ -4,10 +4,12 @@ import zipfile import sys import json +from bs4 import BeautifulSoup +import re ## markdown version 3.1 -''' +""" import numpy as np import matplotlib.pyplot as plt @@ -22,124 +24,129 @@ plt.plot(X,Z,linewidth=5,c="black") plt.savefig("./a.png",dpi=150) plt.show() -''' +""" -def get_all_filenames(the_dir,extensions=[]): + +def get_all_filenames(the_dir, extensions=[]): all_files = [x for x in os.listdir(the_dir)] all_files = [x for x in all_files if x.split(".")[-1] in extensions] return all_files -def get_packageOPF_XML(md_filenames=[],image_filenames=[],css_filenames=[],description_data=None): +def get_packageOPF_XML( + md_filenames=[], image_filenames=[], css_filenames=[], description_data=None +): doc = minidom.Document() - package = doc.createElement('package') - package.setAttribute('xmlns',"http://www.idpf.org/2007/opf") - package.setAttribute('version',"3.0") - package.setAttribute('xml:lang',"en") - package.setAttribute("unique-identifier","pub-id") + package = doc.createElement("package") + package.setAttribute("xmlns", "http://www.idpf.org/2007/opf") + package.setAttribute("version", "3.0") + package.setAttribute("xml:lang", "en") + package.setAttribute("unique-identifier", "pub-id") ## Now building the metadata - metadata = doc.createElement('metadata') - metadata.setAttribute('xmlns:dc', 'http://purl.org/dc/elements/1.1/') + metadata = doc.createElement("metadata") + metadata.setAttribute("xmlns:dc", "http://purl.org/dc/elements/1.1/") - for k,v in description_data["metadata"].items(): + for k, v in description_data["metadata"].items(): if len(v): x = doc.createElement(k) - for metadata_type,id_label in [("dc:title","title"),("dc:creator","creator"),("dc:identifier","book-id")]: - if k==metadata_type: - x.setAttribute('id',id_label) + for metadata_type, id_label in [ + ("dc:title", "title"), + ("dc:creator", "creator"), + ("dc:identifier", "book-id"), + ]: + if k == metadata_type: + x.setAttribute("id", id_label) x.appendChild(doc.createTextNode(v)) metadata.appendChild(x) - ## Now building the manifest - manifest = doc.createElement('manifest') + manifest = doc.createElement("manifest") ## TOC.xhtml file for EPUB 3 - x = doc.createElement('item') - x.setAttribute('id',"toc") - x.setAttribute('properties',"nav") - x.setAttribute('href',"TOC.xhtml") - x.setAttribute('media-type',"application/xhtml+xml") + x = doc.createElement("item") + x.setAttribute("id", "toc") + x.setAttribute("properties", "nav") + x.setAttribute("href", "TOC.xhtml") + x.setAttribute("media-type", "application/xhtml+xml") manifest.appendChild(x) ## Ensure retrocompatibility by also providing a TOC.ncx file - x = doc.createElement('item') - x.setAttribute('id',"ncx") - x.setAttribute('href',"toc.ncx") - x.setAttribute('media-type',"application/x-dtbncx+xml") + x = doc.createElement("item") + x.setAttribute("id", "ncx") + x.setAttribute("href", "toc.ncx") + x.setAttribute("media-type", "application/x-dtbncx+xml") manifest.appendChild(x) - x = doc.createElement('item') - x.setAttribute('id',"titlepage") - x.setAttribute('href',"titlepage.xhtml") - x.setAttribute('media-type',"application/xhtml+xml") + x = doc.createElement("item") + x.setAttribute("id", "titlepage") + x.setAttribute("href", "titlepage.xhtml") + x.setAttribute("media-type", "application/xhtml+xml") manifest.appendChild(x) - for i,md_filename in enumerate(md_filenames): - x = doc.createElement('item') - x.setAttribute('id',"s{:05d}".format(i)) - x.setAttribute('href',"s{:05d}-{}.xhtml".format(i,md_filename.split(".")[0])) - x.setAttribute('media-type',"application/xhtml+xml") + for i, md_filename in enumerate(md_filenames): + x = doc.createElement("item") + x.setAttribute("id", "s{:05d}".format(i)) + x.setAttribute("href", "s{:05d}-{}.xhtml".format(i, md_filename.split(".")[0])) + x.setAttribute("media-type", "application/xhtml+xml") manifest.appendChild(x) - for i,image_filename in enumerate(image_filenames): - x = doc.createElement('item') - x.setAttribute('id',"image-{:05d}".format(i)) - x.setAttribute('href',"images/{}".format(image_filename)) + for i, image_filename in enumerate(image_filenames): + x = doc.createElement("item") + x.setAttribute("id", "image-{:05d}".format(i)) + x.setAttribute("href", "images/{}".format(image_filename)) if "gif" in image_filename: - x.setAttribute('media-type',"image/gif") + x.setAttribute("media-type", "image/gif") elif "jpg" in image_filename: - x.setAttribute('media-type',"image/jpeg") + x.setAttribute("media-type", "image/jpeg") elif "jpeg" in image_filename: - x.setAttribute('media-type',"image/jpg") + x.setAttribute("media-type", "image/jpg") elif "png" in image_filename: - x.setAttribute('media-type',"image/png") - if image_filename==description_data["cover_image"]: - x.setAttribute('properties',"cover-image") + x.setAttribute("media-type", "image/png") + if image_filename == description_data["cover_image"]: + x.setAttribute("properties", "cover-image") ## Ensure compatibility by also providing a meta tag in the metadata - y = doc.createElement('meta') - y.setAttribute('name',"cover") - y.setAttribute('content',"image-{:05d}".format(i)) + y = doc.createElement("meta") + y.setAttribute("name", "cover") + y.setAttribute("content", "image-{:05d}".format(i)) metadata.appendChild(y) manifest.appendChild(x) - for i,css_filename in enumerate(css_filenames): - x = doc.createElement('item') - x.setAttribute('id',"css-{:05d}".format(i)) - x.setAttribute('href',"css/{}".format(css_filename)) - x.setAttribute('media-type',"text/css") + for i, css_filename in enumerate(css_filenames): + x = doc.createElement("item") + x.setAttribute("id", "css-{:05d}".format(i)) + x.setAttribute("href", "css/{}".format(css_filename)) + x.setAttribute("media-type", "text/css") manifest.appendChild(x) ## Now building the spine - spine = doc.createElement('spine') - spine.setAttribute('toc', "ncx") + spine = doc.createElement("spine") + spine.setAttribute("toc", "ncx") - x = doc.createElement('itemref') - x.setAttribute('idref',"titlepage") - x.setAttribute('linear',"yes") + x = doc.createElement("itemref") + x.setAttribute("idref", "titlepage") + x.setAttribute("linear", "yes") spine.appendChild(x) - for i,md_filename in enumerate(all_md_filenames): - x = doc.createElement('itemref') - x.setAttribute('idref',"s{:05d}".format(i)) - x.setAttribute('linear',"yes") + for i, md_filename in enumerate(all_md_filenames): + x = doc.createElement("itemref") + x.setAttribute("idref", "s{:05d}".format(i)) + x.setAttribute("linear", "yes") spine.appendChild(x) - guide = doc.createElement('guide') - x = doc.createElement('reference') - x.setAttribute('type',"cover") - x.setAttribute('title',"Cover image") - x.setAttribute('href',"titlepage.xhtml") + guide = doc.createElement("guide") + x = doc.createElement("reference") + x.setAttribute("type", "cover") + x.setAttribute("title", "Cover image") + x.setAttribute("href", "titlepage.xhtml") guide.appendChild(x) - package.appendChild(metadata) package.appendChild(manifest) package.appendChild(spine) @@ -165,12 +172,17 @@ def get_coverpage_XML(cover_image_path): all_xhtml = """\n""" all_xhtml += """\n""" all_xhtml += """\n\n\n""" - all_xhtml += """\n""".format(cover_image_path) + all_xhtml += ( + """\n""".format( + cover_image_path + ) + ) all_xhtml += """\n""" return all_xhtml -def get_TOC_XML(default_css_filenames,markdown_filenames): + +def get_TOC_XML(default_css_filenames, markdown_filenames): ## Returns the XML data for the TOC.xhtml file toc_xhtml = """\n""" @@ -179,16 +191,23 @@ def get_TOC_XML(default_css_filenames,markdown_filenames): toc_xhtml += """Contents\n""" for css_filename in default_css_filenames: - toc_xhtml += """\n""".format(css_filename) + toc_xhtml += ( + """\n""".format( + css_filename + ) + ) toc_xhtml += """\n\n""" toc_xhtml += """\n\n""" return toc_xhtml + def get_TOCNCX_XML(markdown_filenames): ## Returns the XML data for the TOC.ncx file @@ -196,32 +215,63 @@ def get_TOCNCX_XML(markdown_filenames): toc_ncx += """\n""" toc_ncx += """\n\n""" toc_ncx += """\n""" - for i,md_filename in enumerate(markdown_filenames): + for i, md_filename in enumerate(markdown_filenames): toc_ncx += """\n""".format(i) - toc_ncx += """\n{}\n""".format(md_filename.split(".")[0]) - toc_ncx += """""".format(i,md_filename.split(".")[0]) + toc_ncx += """\n{}\n""".format( + md_filename.split(".")[0] + ) + toc_ncx += """""".format( + i, md_filename.split(".")[0] + ) toc_ncx += """ """ toc_ncx += """\n""" return toc_ncx -def get_chapter_XML(md_filename,css_filenames): + +def parse_classes(element): + el_content = element.string + if el_content == "\n" or el_content == None: + return + return re.findall(r"\s*{[a-zA-Z0-9\s\-\_]+}\s*", el_content) + + +def get_chapter_XML(md_filename, css_filenames): ## Returns the XML data for a given markdown chapter file, with the corresponding css chapter files - with open(os.path.join(work_dir,md_filename),"r",encoding="utf-8") as f: + with open(os.path.join(work_dir, md_filename), "r", encoding="utf-8") as f: markdown_data = f.read() - html_text = markdown.markdown(markdown_data, - extensions=["codehilite","tables","fenced_code","footnotes"], - extension_configs={"codehilite":{"guess_lang":False}} - ) + html_text = markdown.markdown( + markdown_data, + extensions=["codehilite", "tables", "fenced_code", "footnotes"], + extension_configs={"codehilite": {"guess_lang": False}}, + ) + + soup = BeautifulSoup(html_text, features="lxml").body + + for tag in soup.contents: + classes = parse_classes(tag) + if classes: + formated_str_of_classes = re.sub(r"\s*{", "", classes[0]) + formated_str_of_classes = re.sub(r"}\s*", "", formated_str_of_classes) + intermediary = re.sub(r"\s*{.+}\s*", "", tag.string) + tag.string = intermediary + print(tag.string) + tag["class"] = formated_str_of_classes + + html_text = str(soup) + print(html_text) all_xhtml = """\n""" all_xhtml += """\n""" all_xhtml += """\n\n""" - for css_filename in css_filenames: - all_xhtml += """\n""".format(css_filename) + all_xhtml += ( + """\n""".format( + css_filename + ) + ) all_xhtml += """\n\n""" @@ -230,94 +280,109 @@ def get_chapter_XML(md_filename,css_filenames): return all_xhtml + if __name__ == "__main__": - if len(sys.argv[1:])<2: + if len(sys.argv[1:]) < 2: print("\nUsage:\n python md2epub.py ") sys.exit(1) - work_dir = sys.argv[1] output_path = sys.argv[2] - images_dir = os.path.join(work_dir,r'images/') - css_dir = os.path.join(work_dir,r'css/') + images_dir = os.path.join(work_dir, r"images/") + css_dir = os.path.join(work_dir, r"css/") ## Reading the JSON file containing the description of the eBook ## and compiling the list of relevant Markdown, CSS, and image files - with open(os.path.join(work_dir,"description.json"),"r") as f: + with open(os.path.join(work_dir, "description.json"), "r") as f: json_data = json.load(f) - all_md_filenames=[] - all_css_filenames=json_data["default_css"][:] + all_md_filenames = [] + all_css_filenames = json_data["default_css"][:] for chapter in json_data["chapters"]: if not chapter["markdown"] in all_md_filenames: all_md_filenames.append(chapter["markdown"]) if len(chapter["css"]) and (not chapter["css"] in all_css_filenames): all_css_filenames.append(chapter["css"]) - all_image_filenames = get_all_filenames(images_dir,extensions=["gif","jpg","jpeg","png"]) + all_image_filenames = get_all_filenames( + images_dir, extensions=["gif", "jpg", "jpeg", "png"] + ) ###################################################### ## Now creating the ePUB book - os.makedirs(os.path.dirname(output_path), exist_ok=True) + try: + os.makedirs(os.path.dirname(output_path), exist_ok=True) + except FileNotFoundError: + pass - with zipfile.ZipFile(output_path, "w" ) as myZipFile: + with zipfile.ZipFile(output_path, "w") as myZipFile: ## First, write the mimetype - myZipFile.writestr("mimetype","application/epub+zip", zipfile.ZIP_DEFLATED ) + myZipFile.writestr("mimetype", "application/epub+zip", zipfile.ZIP_DEFLATED) ## Then, the file container.xml which just points to package.opf container_data = get_container_XML() - myZipFile.writestr("META-INF/container.xml",container_data, zipfile.ZIP_DEFLATED ) + myZipFile.writestr( + "META-INF/container.xml", container_data, zipfile.ZIP_DEFLATED + ) ## Then, the package.opf file itself - package_data = get_packageOPF_XML(md_filenames=all_md_filenames, - image_filenames=all_image_filenames, - css_filenames=all_css_filenames, - description_data=json_data - ) - myZipFile.writestr("OPS/package.opf",package_data, zipfile.ZIP_DEFLATED) + package_data = get_packageOPF_XML( + md_filenames=all_md_filenames, + image_filenames=all_image_filenames, + css_filenames=all_css_filenames, + description_data=json_data, + ) + myZipFile.writestr("OPS/package.opf", package_data, zipfile.ZIP_DEFLATED) ## First, we create the cover page coverpage_data = get_coverpage_XML(json_data["cover_image"]) - myZipFile.writestr("OPS/titlepage.xhtml",coverpage_data.encode('utf-8'),zipfile.ZIP_DEFLATED) + myZipFile.writestr( + "OPS/titlepage.xhtml", coverpage_data.encode("utf-8"), zipfile.ZIP_DEFLATED + ) ## Now, we are going to convert the Markdown files to xhtml files - for i,chapter in enumerate(json_data["chapters"]): + for i, chapter in enumerate(json_data["chapters"]): chapter_md_filename = chapter["markdown"] chapter_css_filenames = json_data["default_css"][:] if len(chapter["css"]): chapter_css_filenames.append(chapter["css"]) - chapter_data = get_chapter_XML(chapter_md_filename,chapter_css_filenames) - myZipFile.writestr("OPS/s{:05d}-{}.xhtml".format(i,chapter_md_filename.split(".")[0]), - chapter_data.encode('utf-8'), - zipfile.ZIP_DEFLATED) - + chapter_data = get_chapter_XML(chapter_md_filename, chapter_css_filenames) + myZipFile.writestr( + "OPS/s{:05d}-{}.xhtml".format(i, chapter_md_filename.split(".")[0]), + chapter_data.encode("utf-8"), + zipfile.ZIP_DEFLATED, + ) ## Writing the TOC.xhtml file - toc_xml_data = get_TOC_XML(json_data["default_css"],all_md_filenames) - myZipFile.writestr("OPS/TOC.xhtml",toc_xml_data.encode('utf-8'),zipfile.ZIP_DEFLATED) + toc_xml_data = get_TOC_XML(json_data["default_css"], all_md_filenames) + myZipFile.writestr( + "OPS/TOC.xhtml", toc_xml_data.encode("utf-8"), zipfile.ZIP_DEFLATED + ) ## Writing the TOC.ncx file toc_ncx_data = get_TOCNCX_XML(all_md_filenames) - myZipFile.writestr("OPS/toc.ncx",toc_ncx_data.encode('utf-8'),zipfile.ZIP_DEFLATED) + myZipFile.writestr( + "OPS/toc.ncx", toc_ncx_data.encode("utf-8"), zipfile.ZIP_DEFLATED + ) ## Copy image files - for i,image_filename in enumerate(all_image_filenames): - with open(os.path.join(images_dir,image_filename),"rb") as f: + for i, image_filename in enumerate(all_image_filenames): + with open(os.path.join(images_dir, image_filename), "rb") as f: filedata = f.read() - myZipFile.writestr("OPS/images/{}".format(image_filename), - filedata, - zipfile.ZIP_DEFLATED) + myZipFile.writestr( + "OPS/images/{}".format(image_filename), filedata, zipfile.ZIP_DEFLATED + ) ## Copy CSS files - for i,css_filename in enumerate(all_css_filenames): - with open(os.path.join(css_dir,css_filename),"rb") as f: + for i, css_filename in enumerate(all_css_filenames): + with open(os.path.join(css_dir, css_filename), "rb") as f: filedata = f.read() - myZipFile.writestr("OPS/css/{}".format(css_filename), - filedata, - zipfile.ZIP_DEFLATED) + myZipFile.writestr( + "OPS/css/{}".format(css_filename), filedata, zipfile.ZIP_DEFLATED + ) print("eBook creation complete") From 3191ba9cb7befe7029c2e8ed335e40820a81b745 Mon Sep 17 00:00:00 2001 From: Artiom Gutan Date: Mon, 1 Dec 2025 17:56:17 +0200 Subject: [PATCH 06/18] remove debug code --- mark2epub.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/mark2epub.py b/mark2epub.py index 5ceddea..555405e 100755 --- a/mark2epub.py +++ b/mark2epub.py @@ -256,11 +256,9 @@ def get_chapter_XML(md_filename, css_filenames): formated_str_of_classes = re.sub(r"}\s*", "", formated_str_of_classes) intermediary = re.sub(r"\s*{.+}\s*", "", tag.string) tag.string = intermediary - print(tag.string) tag["class"] = formated_str_of_classes html_text = str(soup) - print(html_text) all_xhtml = """\n""" all_xhtml += """\n""" From 361ee196a901410b5b82235e52e6a106bdb35b5a Mon Sep 17 00:00:00 2001 From: Artiom Gutan Date: Mon, 1 Dec 2025 18:03:21 +0200 Subject: [PATCH 07/18] update README --- README.md | 40 +++++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 288c0a0..17f490d 100755 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ mark2epub requires: - Python (>= 3.4) - markdown (>= 3.1) +- BeautifulSoup4 (>= 4.14.3) ### Running mark2epub @@ -18,22 +19,24 @@ The syntax for mark2epub is the following: $ python md2epub.py +The output file path can contain unexisting directories, as the code will handle their creation. + The directory `epub_md` is a sample markdown directory for mark2epub. Note that the directory `markdown_directory` **must** contain -* Markdown `.md` files. Each file represent a chapter in the resulting ePub. -They are processed by name order, and will appear correspondingly in the e-book. +- Markdown `.md` files. Each file represent a chapter in the resulting ePub. + They are processed by name order, and will appear correspondingly in the e-book. -* An `images` folder, containing the images to be included. Only GIF (`.gif` +- An `images` folder, containing the images to be included. Only GIF (`.gif` extension), JPEG (`.jpg` or `.jpeg` extensions), and PNG (`.png` extension) - files are currently supported. This folder is *not* processed recursively, so + files are currently supported. This folder is _not_ processed recursively, so all images should be placed at the root of this folder. -* A `css` folder, containing the CSS files. This folder is *not* processed - recursively, so all css files should be placed at the root of this folder. +- A `css` folder, containing the CSS files. This folder is _not_ processed + recursively, so all css files should be placed at the root of this folder. -* A `description.json` containing meta-information about the e-book. The key +- A `description.json` containing meta-information about the e-book. The key `cover_image` should indicate the name of the cover image. The key `default_css` is a list of css file names that are applied by default on all chapters. @@ -42,9 +45,24 @@ They are processed by name order, and will appear correspondingly in the e-book. the name of the css file that should be applied specifically to this chapter. See the example in the repository for a typical `description.json` file. +## Specific class names for elements + +After your text, on every line (at the end of it) you can add class names to that element, as a list separated by whitespaces, included in curly braces, as follows: + +```markdown +# Chapter 1 {name1 name-2 names-of-names etc} +``` + +Class names can consist of latin letters, digits, hyphens and underscores. + +After compiling the braces will be removed. + +The code won't handle any typos in class names, so it's up to you to handle it. + ## Limitations/Features to be addressed -* Robustness checks in the `mark2epub.py` script -* Recursive processing of the `images` and `css` folders -* Support for additional fonts -* Support for mathematical notation +- Robustness checks in the `mark2epub.py` script +- Recursive processing of the `images` and `css` folders +- Support for additional fonts +- Support for mathematical notation +- Error handling for class names parsing From 3c3fd937b9f6e1f1eb0d32dcd5a00a33a7e0916e Mon Sep 17 00:00:00 2001 From: Artiom Gutan Date: Mon, 1 Dec 2025 18:06:08 +0200 Subject: [PATCH 08/18] basic example of the new feature --- .gitignore | 1 + epub_md/css/general.css | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 80524b8..1eb7c86 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ share/python-wheels/ *.egg MANIFEST out/ +*.epub # PyInstaller # Usually these files are written by a python script from a template diff --git a/epub_md/css/general.css b/epub_md/css/general.css index 07688a7..bfe33b4 100755 --- a/epub_md/css/general.css +++ b/epub_md/css/general.css @@ -2,6 +2,10 @@ body { font-size: 0.8em; } +.example { + color: red; +} + img { max-width: 100%; height: auto; From 93b14f9f76877cb059923ab07583c419c3a1481e Mon Sep 17 00:00:00 2001 From: Artiom Gutan Date: Fri, 2 Jan 2026 00:01:44 +0200 Subject: [PATCH 09/18] Todo so I won't forget it later --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 17f490d..7d83939 100755 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ After your text, on every line (at the end of it) you can add class names to tha Class names can consist of latin letters, digits, hyphens and underscores. -After compiling the braces will be removed. +After compiling the braces and their insides will be removed. The code won't handle any typos in class names, so it's up to you to handle it. @@ -65,4 +65,9 @@ The code won't handle any typos in class names, so it's up to you to handle it. - Recursive processing of the `images` and `css` folders - Support for additional fonts - Support for mathematical notation -- Error handling for class names parsing + +## TODO + +- [ ] Error handling for class names parsing +- [ ] Add support for symlinks +- [ ] `File "mark2epub.py", line 263, in FileNotFoundError: [Errno 2] No such file or directory: '' ` because of no `./` in output path From b8a03e2d4241f1dcc8c083e8c03274dc36e1cd1a Mon Sep 17 00:00:00 2001 From: Artiom Gutan Date: Fri, 2 Jan 2026 00:24:30 +0200 Subject: [PATCH 10/18] dependency fixes and parser fix --- mark2epub.py | 1 + requirements.txt | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/mark2epub.py b/mark2epub.py index 555405e..a0c4084 100755 --- a/mark2epub.py +++ b/mark2epub.py @@ -5,6 +5,7 @@ import sys import json from bs4 import BeautifulSoup +import lxml import re ## markdown version 3.1 diff --git a/requirements.txt b/requirements.txt index 55c7ee1..30b338c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ beautifulsoup4==4.14.3 +lxml==6.0.2 Markdown==3.10 -pip==25.3 +pyinstaller==6.17.0 From 7a11ff7cf2a815a514eea6c98922140c559bb6e5 Mon Sep 17 00:00:00 2001 From: Artiom Gutan Date: Fri, 2 Jan 2026 11:32:29 +0200 Subject: [PATCH 11/18] possibility to not add a cover --- README.md | 5 +++++ mark2epub.py | 11 +++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7d83939..a1b06d4 100755 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ mark2epub requires: - Python (>= 3.4) - markdown (>= 3.1) - BeautifulSoup4 (>= 4.14.3) +- lxml (>= 6.0.2) ### Running mark2epub @@ -59,6 +60,10 @@ After compiling the braces and their insides will be removed. The code won't handle any typos in class names, so it's up to you to handle it. +## No cover + +To not set any cover, simply leave the `"cover_image": ""`. + ## Limitations/Features to be addressed - Robustness checks in the `mark2epub.py` script diff --git a/mark2epub.py b/mark2epub.py index a0c4084..cfac861 100755 --- a/mark2epub.py +++ b/mark2epub.py @@ -337,10 +337,13 @@ def get_chapter_XML(md_filename, css_filenames): myZipFile.writestr("OPS/package.opf", package_data, zipfile.ZIP_DEFLATED) ## First, we create the cover page - coverpage_data = get_coverpage_XML(json_data["cover_image"]) - myZipFile.writestr( - "OPS/titlepage.xhtml", coverpage_data.encode("utf-8"), zipfile.ZIP_DEFLATED - ) + if json_data["cover_image"] != "": + coverpage_data = get_coverpage_XML(json_data["cover_image"]) + myZipFile.writestr( + "OPS/titlepage.xhtml", + coverpage_data.encode("utf-8"), + zipfile.ZIP_DEFLATED, + ) ## Now, we are going to convert the Markdown files to xhtml files for i, chapter in enumerate(json_data["chapters"]): From 880a640692464e804c0af1e616782462e9ca38b6 Mon Sep 17 00:00:00 2001 From: Artiom Gutan Date: Fri, 2 Jan 2026 11:47:00 +0200 Subject: [PATCH 12/18] symlink support, although it seemed to be working without any explicit instructions --- README.md | 3 +-- mark2epub.py | 13 +++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a1b06d4..2645c8f 100755 --- a/README.md +++ b/README.md @@ -74,5 +74,4 @@ To not set any cover, simply leave the `"cover_image": ""`. ## TODO - [ ] Error handling for class names parsing -- [ ] Add support for symlinks -- [ ] `File "mark2epub.py", line 263, in FileNotFoundError: [Errno 2] No such file or directory: '' ` because of no `./` in output path +- [x] Add support for symlinks diff --git a/mark2epub.py b/mark2epub.py index cfac861..94fddb3 100755 --- a/mark2epub.py +++ b/mark2epub.py @@ -240,7 +240,14 @@ def parse_classes(element): def get_chapter_XML(md_filename, css_filenames): ## Returns the XML data for a given markdown chapter file, with the corresponding css chapter files - with open(os.path.join(work_dir, md_filename), "r", encoding="utf-8") as f: + # Setup path of the md file + file_path = os.path.join(work_dir, md_filename) + + # Check if the said file is symlink, and if so - reassign the file_path with the absolute path of the symlink target + if os.path.islink(file_path): + file_path = os.path.abspath(os.readlink(os.path.join(work_dir, md_filename))) + + with open(file_path, "r", encoding="utf-8") as f: markdown_data = f.read() html_text = markdown.markdown( markdown_data, @@ -384,7 +391,9 @@ def get_chapter_XML(md_filename, css_filenames): with open(os.path.join(css_dir, css_filename), "rb") as f: filedata = f.read() myZipFile.writestr( - "OPS/css/{}".format(css_filename), filedata, zipfile.ZIP_DEFLATED + "OPS/css/{}".format(css_filename), + filedata, + zipfile.ZIP_DEFLATED, ) print("eBook creation complete") From 581a433c2e4f4808652c4e123ce05dfa20fd348d Mon Sep 17 00:00:00 2001 From: Artiom Gutan Date: Tue, 13 Jan 2026 23:08:44 +0200 Subject: [PATCH 13/18] obsidian tags support, basic text preprocessor that probably will be expanded later --- README.md | 9 +++++ epub_md/chapter1.md | 2 ++ epub_md/description.json | 47 +++++++++++++------------ file_preprocessor.py | 75 ++++++++++++++++++++++++++++++++++++++++ mark2epub.py | 14 +++++--- 5 files changed, 120 insertions(+), 27 deletions(-) create mode 100644 file_preprocessor.py diff --git a/README.md b/README.md index 2645c8f..68b1272 100755 --- a/README.md +++ b/README.md @@ -60,6 +60,14 @@ After compiling the braces and their insides will be removed. The code won't handle any typos in class names, so it's up to you to handle it. +## Obsidian tags + +You can safely use obsidian text in your markdown files, as the preprocessor will handle it's exclusion from the final built epub. + +You can also specify which style of new line you use (before the tag, after it, or without any newline before or after the tag) in the description.json, `tag_new_line_style` field. + +The valid values are: `before`, `after` and `zero`, respectively, in upper, lower or mixed case (you do you!). + ## No cover To not set any cover, simply leave the `"cover_image": ""`. @@ -75,3 +83,4 @@ To not set any cover, simply leave the `"cover_image": ""`. - [ ] Error handling for class names parsing - [x] Add support for symlinks +- [x] Add support for obsidian tags diff --git a/epub_md/chapter1.md b/epub_md/chapter1.md index 308e5fe..62e796a 100755 --- a/epub_md/chapter1.md +++ b/epub_md/chapter1.md @@ -2,6 +2,8 @@ This is some simple text in Markdown. {WORKS ON EVERY TAG ALLEGEDLY THO} +#example_of_an_obsidian_tag + ## Section 1 {Allows only whitelisted characters google it} Denique Antiochensis **ordinis vertices sub uno elogio iussit occidi** ideo efferatus, diff --git a/epub_md/description.json b/epub_md/description.json index 7057383..1109cf8 100755 --- a/epub_md/description.json +++ b/epub_md/description.json @@ -1,24 +1,25 @@ { -"metadata":{ - "dc:title":"Mark2Epub Sample", - "dc:creator":"Mark2Epub", - "dc:language":"en-US", - "dc:identifier":"mark2epub-sample", - "dc:source":"", - "meta":"", - "dc:date":"2023-01-01", - "dc:publisher":"", - "dc:contributor":"", - "dc:rights":"", - "dc:description":"", - "dc:subject":"" - }, -"cover_image":"cover.jpg", -"default_css":["code_styles.css","general.css"], -"chapters":[ - {"markdown":"chapter1.md","css":""}, - {"markdown":"chapter2.md","css":""}, - {"markdown":"chapter3.md","css":""}, - {"markdown":"chapter4.md","css":"specific.css"} - ] -} \ No newline at end of file + "metadata": { + "dc:title": "Mark2Epub Sample", + "dc:creator": "Mark2Epub", + "dc:language": "en-US", + "dc:identifier": "mark2epub-sample", + "dc:source": "", + "meta": "", + "dc:date": "2023-01-01", + "dc:publisher": "", + "dc:contributor": "", + "dc:rights": "", + "dc:description": "", + "dc:subject": "" + }, + "tag_new_line_style": "after", + "cover_image": "cover.jpg", + "default_css": ["code_styles.css", "general.css"], + "chapters": [ + { "markdown": "chapter1.md", "css": "" }, + { "markdown": "chapter2.md", "css": "" }, + { "markdown": "chapter3.md", "css": "" }, + { "markdown": "chapter4.md", "css": "specific.css" } + ] +} diff --git a/file_preprocessor.py b/file_preprocessor.py new file mode 100644 index 0000000..a686e07 --- /dev/null +++ b/file_preprocessor.py @@ -0,0 +1,75 @@ +""" +Docstring for file_preprocessor + +Author: Artiom Guțan + +This is a module to be used before parsing an md file, to eliminate any comments/hashtags (e.g. Obsidian). +A comment, in this sense, is any line that starts with a hashtag "#" and doesn't contain any whitespaces and ends with a new-line character sequence (\n). +While using this module it is recomended to specify what blank line must be deleted along with the hashtag line itself. For this purpose the enum HTagNLStyle exists. + +HTagNLStyle.BEFORE will remove the line before the hashtag as follows: +# Example of a header +-> +->#hashtag + +Just a normal paragraph + +--- + +HTagNLStyle.AFTER, which is the default one, will remove the line after the hashtag as follows: +# Example of a header + +->#hashtag +-> +Just a normal paragraph + +--- + +HTagNLStyle.ZERO, will remove just the hashtag line: +# Example of a header + +->#hashtag + +Just a normal paragraph + +--- + +I don't really know if it's useful or not, but I implemented it anyways. + +""" + +# + +import sys +import re +from enum import Enum + + +class HTagNLStyle(Enum): + BEFORE = -1 + ZERO = 0 + AFTER = 1 + + +def delete_tags(path: str, style=HTagNLStyle.AFTER) -> str: + md_file = open(path, "r", encoding="utf-8") + lines = md_file.readlines() + + for line in lines: + htag_match = re.fullmatch(r"^\#\S+\n$", line) + if htag_match: + try: + if lines.index(line) != 0 and style.value != 0: + lines.pop(lines.index(line) + style.value) + lines.remove(line) + except IndexError: + lines.remove(line) + + contents = "".join(lines) + md_file.close() + + return contents + + +if __name__ == "__main__": + print(delete_tags(sys.argv[1], style=HTagNLStyle.ZERO)) diff --git a/mark2epub.py b/mark2epub.py index 94fddb3..c14ad97 100755 --- a/mark2epub.py +++ b/mark2epub.py @@ -7,6 +7,7 @@ from bs4 import BeautifulSoup import lxml import re +import file_preprocessor ## markdown version 3.1 @@ -237,7 +238,7 @@ def parse_classes(element): return re.findall(r"\s*{[a-zA-Z0-9\s\-\_]+}\s*", el_content) -def get_chapter_XML(md_filename, css_filenames): +def get_chapter_XML(md_filename, css_filenames, tag_new_line_style="AFTER"): ## Returns the XML data for a given markdown chapter file, with the corresponding css chapter files # Setup path of the md file @@ -247,8 +248,9 @@ def get_chapter_XML(md_filename, css_filenames): if os.path.islink(file_path): file_path = os.path.abspath(os.readlink(os.path.join(work_dir, md_filename))) - with open(file_path, "r", encoding="utf-8") as f: - markdown_data = f.read() + markdown_data = file_preprocessor.delete_tags( + file_path, file_preprocessor.HTagNLStyle[tag_new_line_style.upper()] + ) html_text = markdown.markdown( markdown_data, extensions=["codehilite", "tables", "fenced_code", "footnotes"], @@ -359,7 +361,11 @@ def get_chapter_XML(md_filename, css_filenames): if len(chapter["css"]): chapter_css_filenames.append(chapter["css"]) - chapter_data = get_chapter_XML(chapter_md_filename, chapter_css_filenames) + chapter_data = get_chapter_XML( + chapter_md_filename, + chapter_css_filenames, + json_data["tag_new_line_style"], + ) myZipFile.writestr( "OPS/s{:05d}-{}.xhtml".format(i, chapter_md_filename.split(".")[0]), chapter_data.encode("utf-8"), From 0221e9212683e88de1673a709ddf41dd998a3b4e Mon Sep 17 00:00:00 2001 From: Artiom Gutan Date: Tue, 13 Jan 2026 23:11:41 +0200 Subject: [PATCH 14/18] obsidian tag functionality description --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 68b1272..1576988 100755 --- a/README.md +++ b/README.md @@ -62,9 +62,9 @@ The code won't handle any typos in class names, so it's up to you to handle it. ## Obsidian tags -You can safely use obsidian text in your markdown files, as the preprocessor will handle it's exclusion from the final built epub. +You can safely use obsidian tags in your markdown files, as the preprocessor will handle their exclusion from the final built epub. -You can also specify which style of new line you use (before the tag, after it, or without any newline before or after the tag) in the description.json, `tag_new_line_style` field. +You can also specify which style of new line you use (before the tag, after it, or without any newline before or after the tag) in the description.json, `tag_new_aline_style` field. This new line will be excluded along with the tag. The valid values are: `before`, `after` and `zero`, respectively, in upper, lower or mixed case (you do you!). From c2bf6e2804ca4b097ccf1979a8c75bd898718672 Mon Sep 17 00:00:00 2001 From: Artiom Gutan Date: Tue, 13 Jan 2026 23:12:17 +0200 Subject: [PATCH 15/18] spelling mistakes --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1576988..b5745ec 100755 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ The code won't handle any typos in class names, so it's up to you to handle it. You can safely use obsidian tags in your markdown files, as the preprocessor will handle their exclusion from the final built epub. -You can also specify which style of new line you use (before the tag, after it, or without any newline before or after the tag) in the description.json, `tag_new_aline_style` field. This new line will be excluded along with the tag. +You can also specify which style of new line you use (before the tag, after it, or without any newline before or after the tag) in the description.json, `tag_new_line_style` field. This new line will be excluded along with the tag. The valid values are: `before`, `after` and `zero`, respectively, in upper, lower or mixed case (you do you!). From a6ff402897c0674bceb777f3667a47db997c94b6 Mon Sep 17 00:00:00 2001 From: Artiom Gutan Date: Tue, 13 Jan 2026 23:26:50 +0200 Subject: [PATCH 16/18] rework of file preprocessor that allows chain-like processing and is generally better and more elegant than the previous version --- file_preprocessor.py | 58 +++++++++++++++++++++++++++++--------------- mark2epub.py | 8 +++--- 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/file_preprocessor.py b/file_preprocessor.py index a686e07..1814f6c 100644 --- a/file_preprocessor.py +++ b/file_preprocessor.py @@ -51,25 +51,45 @@ class HTagNLStyle(Enum): AFTER = 1 -def delete_tags(path: str, style=HTagNLStyle.AFTER) -> str: - md_file = open(path, "r", encoding="utf-8") - lines = md_file.readlines() - - for line in lines: - htag_match = re.fullmatch(r"^\#\S+\n$", line) - if htag_match: - try: - if lines.index(line) != 0 and style.value != 0: - lines.pop(lines.index(line) + style.value) - lines.remove(line) - except IndexError: - lines.remove(line) - - contents = "".join(lines) - md_file.close() - - return contents +class FilePreprocessor: + def __init__(self, path: str, style: HTagNLStyle = HTagNLStyle.AFTER): + """ + Initiates the preprocessor using path to a file and new line style (enum) + """ + self.md_file = open(path, "r", encoding="utf-8") + self.lines = self.md_file.readlines() + self.style = style + + def delete_tags(self): + """ + Deletes obsidian style tags (check file docstring) + """ + for line in self.lines: + htag_match = re.fullmatch(r"^\#\S+\n$", line) + if htag_match: + try: + if self.lines.index(line) != 0 and self.style.value != 0: + self.lines.pop(self.lines.index(line) + self.style.value) + self.lines.remove(line) + except IndexError: + self.lines.remove(line) + return self + + def close(self): + """ + Closes the file connection + """ + self.md_file.close() + return self + + def get(self) -> str: + """ + :return: Final processed string + :rtype: str + """ + self.close() + return "".join(self.lines) if __name__ == "__main__": - print(delete_tags(sys.argv[1], style=HTagNLStyle.ZERO)) + print(FilePreprocessor(sys.argv[1], style=HTagNLStyle.ZERO).delete_tags().get()) diff --git a/mark2epub.py b/mark2epub.py index c14ad97..89f1306 100755 --- a/mark2epub.py +++ b/mark2epub.py @@ -7,7 +7,7 @@ from bs4 import BeautifulSoup import lxml import re -import file_preprocessor +from file_preprocessor import FilePreprocessor, HTagNLStyle ## markdown version 3.1 @@ -248,8 +248,10 @@ def get_chapter_XML(md_filename, css_filenames, tag_new_line_style="AFTER"): if os.path.islink(file_path): file_path = os.path.abspath(os.readlink(os.path.join(work_dir, md_filename))) - markdown_data = file_preprocessor.delete_tags( - file_path, file_preprocessor.HTagNLStyle[tag_new_line_style.upper()] + markdown_data = ( + FilePreprocessor(file_path, HTagNLStyle[tag_new_line_style.upper()]) + .delete_tags() + .get() ) html_text = markdown.markdown( markdown_data, From 69dd377f623a1db5e49d1a89391fa02f91a2a90f Mon Sep 17 00:00:00 2001 From: Artiom Gutan Date: Wed, 14 Jan 2026 00:26:35 +0200 Subject: [PATCH 17/18] small adjustment in neighbouring line deletion --- file_preprocessor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/file_preprocessor.py b/file_preprocessor.py index 1814f6c..a4e123a 100644 --- a/file_preprocessor.py +++ b/file_preprocessor.py @@ -68,8 +68,9 @@ def delete_tags(self): htag_match = re.fullmatch(r"^\#\S+\n$", line) if htag_match: try: - if self.lines.index(line) != 0 and self.style.value != 0: - self.lines.pop(self.lines.index(line) + self.style.value) + if self.style.value != 0: + if self.lines.index(line) != 0: + self.lines.pop(self.lines.index(line) + self.style.value) self.lines.remove(line) except IndexError: self.lines.remove(line) From ca6320fc827e9e17de844d77f7a76e80aafa2196 Mon Sep 17 00:00:00 2001 From: Artiom Gutan Date: Wed, 14 Jan 2026 02:10:49 +0200 Subject: [PATCH 18/18] upgraded regex to match multiple tags on a single line + example --- epub_md/chapter1.md | 2 +- file_preprocessor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/epub_md/chapter1.md b/epub_md/chapter1.md index 62e796a..ae23b6c 100755 --- a/epub_md/chapter1.md +++ b/epub_md/chapter1.md @@ -2,7 +2,7 @@ This is some simple text in Markdown. {WORKS ON EVERY TAG ALLEGEDLY THO} -#example_of_an_obsidian_tag +#example_of_an_obsidian_tag #multiple_tags ## Section 1 {Allows only whitelisted characters google it} diff --git a/file_preprocessor.py b/file_preprocessor.py index a4e123a..5bfab9b 100644 --- a/file_preprocessor.py +++ b/file_preprocessor.py @@ -65,7 +65,7 @@ def delete_tags(self): Deletes obsidian style tags (check file docstring) """ for line in self.lines: - htag_match = re.fullmatch(r"^\#\S+\n$", line) + htag_match = re.fullmatch(r"^#\w+(?:\s+#\w+)*\n$", line) if htag_match: try: if self.style.value != 0: