diff --git a/README.md b/README.md index ee673a9..8dcf343 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,56 @@ pastefromhtml ============= - -pastefromhtml is a Zim plugin that lets you paste lists, links, text, and now images (thanks to an anonymous commit) from html clipboard data. +pastefromhtml is a Zim plugin that lets you paste lists, links, text, images, and LaTeX/MathML equations from HTML clipboard data. #### Installation +You can install pastefromhtml by creating a folder with files `htmlcdparser.py` and `__init__.py` in your zim/plugins directory. -You can install pastefromhtml plugin in Zim, by creating a folder with files `htmlcdparser.py` and `__init__.py` in your zim/plugins directory. - -For installation in Linux, you can place a folder (with files `htmlcdparser.py` and `__init__.py` inside) in `~/.local/share/zim/plugins` directory. - -pastefromhtml plugin can also be installed by cloning the github repository in zim/plugins directory: +For installation in Linux, place the folder in `~/.local/share/zim/plugins`. +You can also install by cloning the repository: ```sh cd ~/.local/share/zim/plugins git clone https://github.com/cetinkaya/pastefromhtml.git ``` -To enable the plugin, click on the menu entry `Edit / Preferences`, then go to `Plugins` tab. +To enable the plugin, click on `Edit / Preferences`, then go to the `Plugins` tab. +#### Dependencies -#### Use +For basic use (text, links, images) no additional dependencies are needed. + +For **LaTeX/KaTeX equation rendering** (e.g. when pasting from Claude.ai) the following must be installed: +- `latex` and `dvipng` (from TeX Live) + +For **MathML equation rendering** (e.g. when pasting from GitHub Copilot) additionally: +- `python-pypandoc` and `pandoc` + +On Arch/Manjaro: +```sh +sudo pacman -S texlive dvipng python-pypandoc +``` -pastefromhtml adds menu entries (`Tools / Paste from HTML`, `Edit / Paste from HTML`) and a right-click context-menu entry, as well as a keyboard shortcut (`ctrl + shift + v`) in Zim. +On Debian/Ubuntu: +```sh +sudo apt install texlive dvipng pandoc python3-pypandoc +``` + +If the dependencies are not installed, equations are silently skipped and the rest of the content is pasted normally. + +#### Use +pastefromhtml adds menu entries (`Tools / Paste from HTML`, `Edit / Paste from HTML`) and a right-click context-menu entry, as well as a keyboard shortcut (`ctrl + shift + v`) in Zim. -After you copy lists/links/text/images in your browser, paste them into Zim by clicking on the menu entry or by using the shortcut `ctrl + shift + v`. +After you copy content in your browser, paste it into Zim by clicking on the menu entry or by using the shortcut `ctrl + shift + v`. +Equations are automatically rendered to PNG and embedded as images in the page's attachments folder. -#### Quirks +#### Configuration +To access the configuration options, click on `Edit / Preferences`, navigate to the `Plugins` tab, select `Paste from HTML`, and click the `Configure` button. -##### Images inside anchor tags +Available options: -When pasting image tags `` that are placed inside anchor tags ``, the default behavior of pastefromhtml is to only paste the URL that the anchor tag refers to (unless it is the URL of the image). There is an option that can be configured so as to paste the images instead of URLs of the anchor tags. To access this option, you can click on Edit/Preferences in the menu bar, navigate to the Plugins tab, and select Paste from HTML. After that, click on the Configure button located on the right-hand side. +- **Images inside anchor tags** – when pasting `` tags inside `` tags, paste the image instead of the link URL +- **Reload page after pasting** – reload the page after pasting to apply formatting +- **LaTeX equation font size** – font size in pt for rendered equations (default: 14) +- **LaTeX equation image DPI** – resolution of rendered equation images (default: 200) +- **LaTeX dark mode** – use white font color for equations (for dark Zim themes) diff --git a/__init__.py b/__init__.py index 70ca866..c4e981b 100755 --- a/__init__.py +++ b/__init__.py @@ -23,7 +23,14 @@ class PasteFromHTMLPlugin(PluginClass): plugin_info = { "name": _("Paste from HTML"), - "description": _("This plugin lets you paste HTML clipboard data."), + "description": _( + "This plugin lets you paste HTML clipboard data.\n\n" + "For LaTeX/KaTeX equation rendering the following must be installed:\n" + " - latex (texlive)\n" + " - dvipng\n" + " - python-pypandoc (for MathML from Copilot etc.)\n\n" + "On Arch/Manjaro: sudo pacman -S texlive dvipng python-pypandoc" + ), "author": "Ahmet Cetinkaya", "help": "Plugins:Paste from HTML" } @@ -32,6 +39,9 @@ class PasteFromHTMLPlugin(PluginClass): # key, type, label, default ('image_inside_a', 'bool', _('For image tags inside anchor tags , paste images instead of links'), False), ('reload_page', 'bool', _('Reload page after pasting'), False), + ('latex_font_size', 'int', _('LaTeX equation font size (pt)'), 14, (6, 24)), + ('latex_dpi', 'choice', _('LaTeX equation image DPI'), '200', ('96', '120', '150', '200', '300', '400', '600')), + ('latex_dark_mode', 'bool', _('LaTeX equations: use white font (for dark themes)'), False), ) @@ -95,7 +105,12 @@ def pastefh(self): if target in ["text/html", "TEXT/HTML", "HTML Format"]: data = get_fragment(data) cursor = self.window.pageview.get_cursor_pos() - h = HTMLCDParser(self.preferences['image_inside_a']) + h = HTMLCDParser( + self.preferences['image_inside_a'], + latex_font_size=self.preferences['latex_font_size'], + latex_dpi=int(self.preferences['latex_dpi']), + latex_dark_mode=self.preferences['latex_dark_mode'], + ) buffer.insert_at_cursor(h.to_zim(data, folder)) self.window.pageview.set_page(self.window.pageview.page) self.window.pageview.set_cursor_pos(cursor) diff --git a/htmlcdparser.py b/htmlcdparser.py index a212fa3..79ec90e 100755 --- a/htmlcdparser.py +++ b/htmlcdparser.py @@ -17,6 +17,80 @@ import re import urllib.request, urllib.error, urllib.parse import os +import unicodedata +import glob +import subprocess +import tempfile +import shutil + +try: + import pypandoc + HAS_PYPANDOC = True +except ImportError: + HAS_PYPANDOC = False + +def _fix_pandoc_latex(latex_src): + """Fix common issues in pandoc's MathML→LaTeX output.""" + # \overset{˙}{x} → \dot{x} (dot accent) + latex_src = re.sub(r'\\overset\{˙\}\{([^}]+)\}', r'\\dot{\1}', latex_src) + # \overset{¨}{x} → \ddot{x} (double dot accent) + latex_src = re.sub(r'\\overset\{¨\}\{([^}]+)\}', r'\\ddot{\1}', latex_src) + # \overset{`}{x} → \grave{x} + latex_src = re.sub(r'\\overset\{`\}\{([^}]+)\}', r'\\grave{\1}', latex_src) + # \overset{´}{x} → \acute{x} + latex_src = re.sub(r'\\overset\{´\}\{([^}]+)\}', r'\\acute{\1}', latex_src) + # \overset{^}{x} → \hat{x} + latex_src = re.sub(r'\\overset\{\^\}\{([^}]+)\}', r'\\hat{\1}', latex_src) + # \overset{~}{x} → \tilde{x} + latex_src = re.sub(r'\\overset\{~\}\{([^}]+)\}', r'\\tilde{\1}', latex_src) + # \parallel → \| (standard LaTeX norm) + latex_src = latex_src.replace(r'\parallel', r'\|') + return latex_src + + +def _mathml_to_latex(mathml_str): + """Convert a MathML string to LaTeX via pandoc. Returns None on failure.""" + if not HAS_PYPANDOC: + return None + try: + html = '{}'.format(mathml_str) + result = pypandoc.convert_text(html, 'latex', format='html+tex_math_dollars') + result = result.strip() + for wrapper in (r'\[', r'\]', r'\(', r'\)', '$$', '$'): + result = result.replace(wrapper, '') + result = _fix_pandoc_latex(result.strip()) + return result + except Exception: + return None + +# Set to True to open a terminal with a debug log after each paste operation +DEBUG = False + +def _dbg(msg, loglines=None): + if not DEBUG: + return + if loglines is not None: + loglines.append(str(msg)) + +def _show_debug_terminal(lines): + """Write debug log to a tmp file and open it in a terminal.""" + if not DEBUG: + return + log_path = '/tmp/pastefromhtml_debug.log' + with open(log_path, 'w', encoding='utf-8') as f: + f.write('\n'.join(lines)) + # Try common terminal emulators in order + for term in [ + ['terminator', '--new-tab', '-e', 'bash -c "cat {}; echo; echo \'--- press enter to close ---\'; read"'.format(log_path)], + ['gnome-terminal', '--', 'bash', '-c', 'cat {}; echo; echo "--- press enter to close ---"; read'.format(log_path)], + ['xterm', '-e', 'bash', '-c', 'cat {}; echo; echo "--- press enter to close ---"; read'.format(log_path)], + ['konsole', '-e', 'bash', '-c', 'cat {}; echo; echo "--- press enter to close ---"; read'.format(log_path)], + ]: + try: + subprocess.Popen(term) + return + except FileNotFoundError: + continue def assoc(key, pairs): value = None @@ -34,11 +108,163 @@ def get_url(url, name): f.write(urllib.request.urlopen(req).read()) f.close() -# HTML Clipboard Data Parser + +# --- Inline LaTeX renderer (extracted from zim equationeditor plugin) -------- + +LATEX_TEMPLATE = r"""\documentclass[%(font_size)spt]{article} +\usepackage{amsmath} +\usepackage{amssymb} +\usepackage{amsthm} +%(color_preamble)s +\pagestyle{empty} +\begin{document} +%(color_begin)s +\begin{math} +%(equation)s +\end{math} +%(color_end)s +\end{document} +""" + +def _latex_to_png(latex_src, output_png_path, font_size=14, dark_mode=False, dpi=150, log=None): + """ + Renders a LaTeX math expression to a PNG file. + Returns True on success, False on failure. + latex_src: raw LaTeX string (without \\begin{math}...\\end{math} wrapper) + output_png_path: absolute path where the PNG should be written + """ + if log is None: + log = [] + _dbg('=== _latex_to_png called ===', log) + _dbg('latex_src: ' + repr(latex_src), log) + _dbg('output_png_path: ' + output_png_path, log) + + # Filter empty lines (not allowed in latex equation blocks) + lines = [l for l in latex_src.splitlines(True) if l and not l.isspace()] + equation = ''.join(lines).strip() + _dbg('equation after filter: ' + repr(equation), log) + if not equation: + _dbg('ABORT: equation is empty', log) + return False + + if dark_mode: + color_preamble = r'\usepackage{color}' + color_begin = r'\color{white}' + color_end = '' + else: + color_preamble = '' + color_begin = '' + color_end = '' + + tex_source = LATEX_TEMPLATE % { + 'font_size': font_size, + 'equation': equation, + 'color_preamble': color_preamble, + 'color_begin': color_begin, + 'color_end': color_end, + } + + tmpdir = tempfile.mkdtemp(prefix='zim_eq_') + _dbg('tmpdir: ' + tmpdir, log) + try: + tex_path = os.path.join(tmpdir, 'equation.tex') + dvi_path = os.path.join(tmpdir, 'equation.dvi') + png_path = os.path.join(tmpdir, 'equation.png') + + with open(tex_path, 'w', encoding='utf-8') as f: + f.write(tex_source) + _dbg('tex_source written to: ' + tex_path, log) + + # Run latex + result = subprocess.run( + ['latex', '-no-shell-escape', '-halt-on-error', 'equation.tex'], + cwd=tmpdir, + capture_output=True + ) + _dbg('latex returncode: ' + str(result.returncode), log) + _dbg('latex stdout: ' + result.stdout.decode('utf-8', errors='replace'), log) + _dbg('latex stderr: ' + result.stderr.decode('utf-8', errors='replace'), log) + if result.returncode != 0: + _dbg('ABORT: latex failed', log) + return False + + # Run dvipng + result = subprocess.run( + ['dvipng', '-q', '-bg', 'Transparent', '-T', 'tight', + '-D', str(dpi), '-o', png_path, dvi_path], + cwd=tmpdir, + capture_output=True + ) + _dbg('dvipng returncode: ' + str(result.returncode), log) + _dbg('dvipng stdout: ' + result.stdout.decode('utf-8', errors='replace'), log) + _dbg('dvipng stderr: ' + result.stderr.decode('utf-8', errors='replace'), log) + if result.returncode != 0: + _dbg('ABORT: dvipng failed', log) + return False + + _dbg('png_path exists: ' + str(os.path.exists(png_path)), log) + shutil.copy2(png_path, output_png_path) + _dbg('SUCCESS: copied to ' + output_png_path, log) + return True + except Exception as e: + _dbg('EXCEPTION: ' + str(e), log) + return False + finally: + if not DEBUG: + shutil.rmtree(tmpdir, ignore_errors=True) + else: + _dbg('tmpdir kept for inspection (DEBUG=True): ' + tmpdir, log) + + +def _equation_png_name(latex_src): + """Generate a readable filename from the LaTeX source.""" + name = latex_src.strip() + # Replace characters that are problematic in filenames + replacements = { + '\\': '_', + '/': '_', + '{': '_', + '}': '_', + '^': '_', + '*': '_', + '?': '_', + '"': '_', + '<': '_', + '>': '_', + '|': '_', + ':': '_', + ' ': '_', + '\n': '_', + '\t': '_', + '=': 'eq', + '+': 'plus', + '-': 'minus', + } + for char, replacement in replacements.items(): + name = name.replace(char, replacement) + # Collapse multiple underscores + name = re.sub(r'_+', '_', name).strip('_') + # Limit length to avoid filesystem issues + if len(name) > 80: + name = name[:80] + return 'eq_%s.png' % name + + +# --- HTML Clipboard Data Parser ---------------------------------------------- + class HTMLCDParser(HTMLParser): - def __init__(self, image_inside_a): - HTMLParser.__init__(self) # super().__init__() for Pyhon 3 + def __init__(self, image_inside_a, latex_font_size=14, latex_dark_mode=False, latex_dpi=150): + HTMLParser.__init__(self) self.image_inside_a = image_inside_a + + # LaTeX rendering settings + self.latex_font_size = latex_font_size + self.latex_dark_mode = latex_dark_mode + self.latex_dpi = latex_dpi + + # Debug log (populated only when DEBUG=True) + self._log = [] + self.zim_str = "" self.beg = {"h1": "====== ", "h2": "===== ", @@ -71,7 +297,7 @@ def __init__(self, image_inside_a): "tr": "", "th": "|", "td": "|", - "hr": "-----\n", + # hr intentionally omitted – handled in handle_startendtag only "sup": "^{", "sub": "_{", "span": "", @@ -122,12 +348,12 @@ def __init__(self, image_inside_a): self.inside_pre = False self.pre_data = "" self.inside_blockquote = False - self.inside_tag = "" #Indicate label on which we are - self.start_tag = "" #Initial tag in case we have to delete it + self.inside_tag = "" + self.start_tag = "" self.del_tag = "" - self.tag_attrib = "" #Tag Attribute Value + self.tag_attrib = "" self.folder = None - self.a_href = "" #Link of a tag + self.a_href = "" self.inside_li = False self.list_level = -1 self.inside_iframe = False @@ -135,29 +361,140 @@ def __init__(self, image_inside_a): self.inside_dl = False self.inside_table = False + # KaTeX / LaTeX state + self.inside_katex = False # inside any .katex element + self.inside_annotation = False # inside + self.katex_depth = 0 # nesting depth within .katex + self.annotation_src = "" # accumulated raw LaTeX source + self.katex_rendered = False # did we already emit a PNG for current expression? + self.inside_katex_html = False # inside aria-hidden katex-html (skip rendering noise) + + # MathML state (Copilot and other sources) + self.inside_mathml = False # inside a element + self.mathml_depth = 0 # nesting depth (math can contain math) + self.mathml_raw = "" # accumulated raw MathML including tags + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _rebuild_tag(self, tag, attrs): + """Reconstruct an opening HTML tag string from tag name and attrs list.""" + parts = ['<', tag] + for k, v in attrs: + if v is None: + parts.append(' ' + k) + else: + parts.append(' {}="{}"'.format(k, v.replace('"', '"'))) + parts.append('>') + return ''.join(parts) + + def _is_katex(self, attrs): + """Return True if the tag carries a KaTeX CSS class.""" + classes = (assoc('class', attrs) or '').split() + return 'katex' in classes + + def _is_katex_html(self, attrs): + classes = (assoc('class', attrs) or '').split() + return 'katex-html' in classes + + def _is_annotation(self, tag, attrs): + return tag == 'annotation' and assoc('encoding', attrs) == 'application/x-tex' + + def _render_equation(self, latex_src): + """Render latex_src to PNG in self.folder, return Zim image syntax or None.""" + _dbg('=== _render_equation called ===', self._log) + _dbg('folder: ' + str(self.folder), self._log) + _dbg('latex_src: ' + repr(latex_src), self._log) + if not self.folder: + _dbg('ABORT: no folder', self._log) + return None + latex_src = latex_src.strip() + if not latex_src: + _dbg('ABORT: empty latex_src', self._log) + return None + png_name = _equation_png_name(latex_src) + folder_path = str(self.folder) + # folder may be a Zim Dir object – try .path attribute first + if hasattr(self.folder, 'path'): + folder_path = self.folder.path + png_path = os.path.join(folder_path, png_name) + _dbg('png_path: ' + png_path, self._log) + _dbg('already exists: ' + str(os.path.exists(png_path)), self._log) + if not os.path.exists(png_path): + # Ensure folder exists + os.makedirs(folder_path, exist_ok=True) + ok = _latex_to_png(latex_src, png_path, + font_size=self.latex_font_size, + dark_mode=self.latex_dark_mode, + dpi=self.latex_dpi, + log=self._log) + _dbg('_latex_to_png returned: ' + str(ok), self._log) + if not ok: + return None + return '{{./%s}}' % png_name + + # ------------------------------------------------------------------ + # Tag handlers + # ------------------------------------------------------------------ + def handle_starttag(self, tag, attrs): - #If we are in a non-nestable tag we do nothing + # ---- MathML handling (Copilot etc.) ---- + if tag == 'math' and not self.inside_katex: + if self.inside_mathml: + # nested math – just accumulate as raw + self.mathml_depth += 1 + self.mathml_raw += self._rebuild_tag(tag, attrs) + else: + self.inside_mathml = True + self.mathml_depth = 1 + self.mathml_raw = self._rebuild_tag(tag, attrs) + _dbg('MathML START', self._log) + return + + if self.inside_mathml: + self.mathml_raw += self._rebuild_tag(tag, attrs) + self.mathml_depth += 1 + return + + # ---- KaTeX handling ---- + if self._is_katex(attrs): + _dbg('KaTeX START tag={} classes={}'.format(tag, assoc('class', attrs)), self._log) + self.inside_katex = True + self.katex_depth = 1 + self.katex_rendered = False + self.annotation_src = "" + return + + if self.inside_katex: + self.katex_depth += 1 + if self._is_katex_html(attrs): + self.inside_katex_html = True + if self._is_annotation(tag, attrs): + _dbg('annotation START (depth={})'.format(self.katex_depth), self._log) + self.inside_annotation = True + self.annotation_src = "" + return # suppress all normal processing inside katex + + # ---- Normal processing ---- if self.inside_tag and not (self.inside_tag == "a" and tag == "img" and self.a_href) and not(self.inside_tag == "th" or self.inside_tag == "td" or self.inside_tag == "dt" or self.inside_tag == "dd") and not (tag == "a" and (self.inside_tag == "b" or self.inside_tag == "strong" or self.inside_tag == "i" or self.inside_tag == "em" or self.inside_tag == "u" or self.inside_tag == "ins" or self.inside_tag == "mark" or self.inside_tag == "strike" or self.inside_tag == "del") and self.zim_str.endswith(self.beg[self.inside_tag])): return if tag == "blockquote": self.inside_blockquote = True - #If the tag a is in a non-nestable one, tag a prevails and the previous one is deleted. In block sentences it is not done if tag == "a" and self.inside_tag and ((self.inside_tag != "pre" and self.inside_tag != "code")): self.del_tag = self.inside_tag self.zim_str = self.zim_str[:len(self.zim_str)-len(self.start_tag)] - #Initialize non-nestable tag - if tag != "td" and tag != "dd" and self.beg.get(tag) or tag == "a" and not self.inside_tag: + if tag != "td" and tag != "dd" and self.beg.get(tag) or tag == "a" and not self.inside_tag: self.inside_tag = tag - if (tag == "pre" or tag == "code"): #If pre in p + if (tag == "pre" or tag == "code"): self.inside_pre = True if tag in list(self.beg.keys()): - #Add blank when tag not start line if self.zim_str.endswith(("\n", "(", "[", "\t", "\"", " ", "/", '\xa0')): blank = "" else: blank = " " self.zim_str += blank + self.beg[tag] - self.start_tag = self.beg[tag] #Store start tag to delete it could be somewhere else + self.start_tag = self.beg[tag] if tag == "p": self.inside_p = True if self.inside_blockquote: @@ -181,49 +518,41 @@ def handle_starttag(self, tag, attrs): self.tag_attrib = " (" + datetime + ")" elif tag == "a": href = assoc("href", attrs) - self.a_href = href #ref of tag + self.a_href = href if href is None: href = "#" - #Add blank when tag not start line if self.zim_str.endswith(("\n", "(", "[", "\t", "\"", " ", "/", '\xa0')): blank = "" else: blank = " " - #If we are in a table we escape | if self.inside_table: pipe = "\|" else: pipe = "|" self.zim_str += blank + "[[{}".format(href) + pipe elif tag == "ol": - #if we are in a definition list the tab is not put to the dd if self.inside_dl and self.zim_str.endswith("\t"): - self.zim_str = self.zim_str[:len(self.zim_str)-len("\t")] - #If it is not at the beginning of the line an enter is added + self.zim_str = self.zim_str[:len(self.zim_str)-len("\t")] if self.zim_str and not self.zim_str.endswith("\n"): self.zim_str += "\n" self.list_type = "ol" self.item_no = 0 self.list_level += 1 elif tag == "ul": - #if we are in a definition list the tab is not put to the dd if self.inside_dl and self.zim_str.endswith("\t"): - self.zim_str = self.zim_str[:len(self.zim_str)-len("\t")] - #If it is not at the beginning of the line an enter is added + self.zim_str = self.zim_str[:len(self.zim_str)-len("\t")] if self.zim_str and not self.zim_str.endswith("\n"): self.zim_str += "\n" self.list_type = "ul" self.item_no = 0 self.list_level += 1 elif tag == "li": - #If you are in a blockquote add tab if self.inside_blockquote: self.zim_str += "\t" - #If tag li no close add enter if self.inside_li and (self.zim_str and not self.zim_str.endswith("\n")): self.zim_str += "\n" self.item_no += 1 - self.zim_str += "\t" * self.list_level #Add level + self.zim_str += "\t" * self.list_level if self.list_type == "ol": self.zim_str += str(self.item_no) + ". " else: @@ -237,10 +566,8 @@ def handle_starttag(self, tag, attrs): if alt is None: alt = "Image" if src != "#" and not self.inside_table: - #If the image and the link match, only the image remains and the label is deleted if self.inside_tag == "a" and src == self.a_href: self.zim_str = self.zim_str[:len(self.zim_str)-len("[[" + self.a_href + "|")] - #Si img inside a an <> image then prevails a if self.inside_tag == "a" and src != self.a_href: return qmark_index = src.find('?') @@ -268,23 +595,65 @@ def handle_starttag(self, tag, attrs): self.inside_table = True def handle_endtag(self, tag): - if self.inside_tag and tag != self.inside_tag and not (self.inside_tag == "th" or self.inside_tag == "td" or self.inside_tag == "dt" or self.inside_tag == "dd" ) and not (tag == "a" and (self.inside_tag == "b" or self.inside_tag == "strong" or self.inside_tag == "i" or self.inside_tag == "em" or self.inside_tag == "u" or self.inside_tag == "ins" or self.inside_tag == "mark" or self.inside_tag == "strike" or self.inside_tag == "del") and self.del_tag): + # ---- MathML handling ---- + if self.inside_mathml: + self.mathml_depth -= 1 + if self.mathml_depth <= 0: + # closing – finalize + self.mathml_raw += '' + _dbg('MathML END raw={}'.format(repr(self.mathml_raw[:80])), self._log) + latex_src = _mathml_to_latex(self.mathml_raw) + _dbg('MathML→LaTeX: {}'.format(repr(latex_src)), self._log) + if latex_src: + zim_img = self._render_equation(latex_src) + _dbg('zim_img result: ' + str(zim_img), self._log) + if zim_img: + if not self.zim_str.endswith((" ", "\n", "\t")): + self.zim_str += " " + self.zim_str += zim_img + self.inside_mathml = False + self.mathml_depth = 0 + self.mathml_raw = "" + else: + self.mathml_raw += ''.format(tag) + return + + # ---- KaTeX handling ---- + if self.inside_katex: + if tag == 'annotation' and self.inside_annotation: + _dbg('annotation END src={}'.format(repr(self.annotation_src)), self._log) + if self.annotation_src.strip() and not self.katex_rendered: + zim_img = self._render_equation(self.annotation_src) + _dbg('zim_img result: ' + str(zim_img), self._log) + if zim_img: + if not self.zim_str.endswith((" ", "\n", "\t")): + self.zim_str += " " + self.zim_str += zim_img + self.katex_rendered = True + self.inside_annotation = False + self.annotation_src = "" + self.katex_depth -= 1 + _dbg('KaTeX END tag={} depth now={}'.format(tag, self.katex_depth), self._log) + if self.katex_depth <= 0: + self.inside_katex = False + self.inside_katex_html = False + self.katex_depth = 0 + return + + # ---- Normal processing ---- + if self.inside_tag and tag != self.inside_tag and not (self.inside_tag == "th" or self.inside_tag == "td" or self.inside_tag == "dt" or self.inside_tag == "dd") and not (tag == "a" and (self.inside_tag == "b" or self.inside_tag == "strong" or self.inside_tag == "i" or self.inside_tag == "em" or self.inside_tag == "u" or self.inside_tag == "ins" or self.inside_tag == "mark" or self.inside_tag == "strike" or self.inside_tag == "del") and self.del_tag): return if tag == "blockquote": self.inside_blockquote = False - #end of nestable tag if self.inside_tag == tag: - self.inside_tag = ""; - #Init href of tag a + self.inside_tag = "" if tag == "a": self.a_href = "" - #If you tag this within another non-nestable it is deleted if self.del_tag == tag: self.start_tag = "" self.del_tag = "" return if (tag == "pre" or tag == "code"): - #If tag empty del start tag if not self.pre_data: if self.zim_str.endswith("''"): self.zim_str = self.zim_str[:len(self.zim_str) - 2] @@ -292,12 +661,10 @@ def handle_endtag(self, tag): self.inside_pre = False return if self.pre_data.count('\n') > 0: - #Initial tag if self.zim_str.endswith("''"): self.zim_str = self.zim_str[:len(self.zim_str) - 2] self.zim_str += "\n'''\n" self.zim_str += self.pre_data - #Final Tag if self.pre_data.count('\n') > 0: self.zim_str += "\n'''\n" else: @@ -305,7 +672,6 @@ def handle_endtag(self, tag): self.pre_data = "" self.inside_pre = False return - #Remove enter before tr, td, th if tag == "tr" or tag == "td" or tag == "th": if self.zim_str.endswith("\n"): self.zim_str = self.zim_str[:len(self.zim_str) - len("\n")] @@ -340,71 +706,84 @@ def handle_endtag(self, tag): if tag in list(self.end.keys()): self.start_tag = "" if tag == "li": - #If li empty del if self.list_type == "ul" and self.zim_str.endswith("* "): self.zim_str = self.zim_str[:len(self.zim_str) - 2] elif self.list_type == "ol" and self.zim_str.endswith(str(self.item_no) + ". "): self.zim_str = self.zim_str[:len(self.zim_str) - len(str(self.item_no) + ". ")] self.item_no -= 1 - #Add enter if not exists in li tag elif not self.zim_str.endswith("\n"): self.zim_str += "\n" else: - #If we are not at a level higher than the first level of a list and not two last tag finish in \n if not ((tag == "ol" or tag == "ul") and self.list_level >= 0): - #If tag empty then delete if tag in list(self.beg.keys()) and self.beg[tag] and self.zim_str.endswith(self.beg[tag]): self.zim_str = self.zim_str[:len(self.zim_str) - len(self.beg[tag])] else: - #If not duplicate \n if not ((tag == "ol" or tag == "ul") and self.zim_str.endswith("\n")): self.zim_str += self.end[tag] - #If tag li end inside_li false if tag == "li" or tag == "ul" or tag == "ol": - self.inside_li = False + self.inside_li = False def handle_data(self, data): - if self.inside_pre: #not clean + data = unicodedata.normalize('NFC', data) + + # Inside MathML – accumulate raw content + if self.inside_mathml: + self.mathml_raw += data + return + + # Inside a KaTeX annotation – collect raw LaTeX + if self.inside_annotation: + _dbg('annotation DATA: ' + repr(data), self._log) + self.annotation_src += data + return + + # Inside katex but not annotation – suppress rendered HTML noise + if self.inside_katex: + return + + if self.inside_pre: self.pre_data += data else: if self.inside_iframe: space_removed_data = "" else: - #Put as a literal syntax of zim + # Escape Zim wiki syntax that might appear in pasted text data = re.sub('(\'\'.+\'\')', r"''\1''", data) data = re.sub('(\[\[.*\]\])', r"''\1''", data) - data = re.sub('(^=+ .+ =+)', r"''\1''", data) - data = re.sub('(^\t*\* )', r"''\1''", data) - data = re.sub('(^\t*\[[ *x]\] )', r"''\1''", data) + data = re.sub(r'(^=+ .+ =+)', r"''\1''", data, flags=re.MULTILINE) + data = re.sub(r'(^\t*\* )', r"''\1''", data, flags=re.MULTILINE) + data = re.sub(r'(^\t*\[[ *x]\] )', r"''\1''", data, flags=re.MULTILINE) data = re.sub('(\*\*.+\*\*)', r"''\1''", data) data = re.sub('(\/\/.+\/\/)', r"''\1''", data) data = re.sub('(__.+__)', r"''\1''", data) data = re.sub('(~~.+~~)', r"''\1''", data) data = re.sub('(\^\{.+\})', r"''\1''", data) data = re.sub('(\_\{.+\})', r"''\1''", data) - #If we are in a span tag, rstrip does not apply + # Escape Zim horizontal rule syntax (5+ dashes on a line) + data = re.sub(r'^-{5,}$', r"''\g<0>''", data, flags=re.MULTILINE) if self.inside_span: - space_removed_data = re.sub(r"[\s]+", " ", data) + space_removed_data = re.sub(r"[\s]+", " ", data) else: space_removed_data = re.sub(r"[\s]+", " ", data.rstrip()) - #If we are on a table they escape \ n and | - if self.inside_table: - space_removed_data = space_removed_data.replace("|", "\|") - space_removed_data = space_removed_data.replace("\n", "\\n") + if self.inside_table: + space_removed_data = space_removed_data.replace("|", "\|") + space_removed_data = space_removed_data.replace("\n", "\\n") self.zim_str += space_removed_data def handle_entityref(self, name): if name in name2codepoint: - c = chr(name2codepoint[name]) + c = unicodedata.normalize('NFC', chr(name2codepoint[name])) + else: + return + if self.inside_katex: + return if self.inside_pre: - #Add blank when tag not start line if self.pre_data.endswith(("\n", "(", "[", "\t", "\"", " ", "/", '\xa0')): blank = "" else: blank = " " self.pre_data += blank + c else: - #Add blank when tag not start line if self.zim_str.endswith(("\n", "(", "[", "\t", "\"", " ", "/", '\xa0')): blank = "" else: @@ -416,7 +795,9 @@ def handle_charref(self, name): c = chr(int(name[1:], 16)) else: c = chr(int(name)) - #Add blank when tag not start line + c = unicodedata.normalize('NFC', c) + if self.inside_katex: + return if self.zim_str.endswith(("\n", "(", "[", "\t", "\"", " ", "/", '\xa0')): blank = "" else: @@ -424,6 +805,15 @@ def handle_charref(self, name): self.zim_str += blank + c def handle_startendtag(self, tag, attrs): + # Inside MathML – accumulate self-closing tags as raw + if self.inside_mathml: + self.mathml_raw += self._rebuild_tag(tag, attrs)[:-1] + '/>' + return + + # ---- KaTeX self-closing tags – just track depth ---- + if self.inside_katex: + return + if tag == "br": self.zim_str += "\n\n" elif tag == "input": @@ -441,10 +831,8 @@ def handle_startendtag(self, tag, attrs): if alt is None: alt = "Image" if src != "#" and not self.inside_table: - #If the image and the link match, only the image remains and the label is deleted if self.inside_tag == "a" and src == self.a_href: self.zim_str = self.zim_str[:len(self.zim_str)-len("[[" + self.a_href + "|")] - #If img inside a an <> image then prevails a if not image_inside_a if self.inside_tag == "a" and src != self.a_href: if self.image_inside_a: self.zim_str = self.zim_str[:len(self.zim_str)-len("[[" + self.a_href + "|")] @@ -464,10 +852,14 @@ def handle_startendtag(self, tag, attrs): else: self.zim_str += "[[{0}|{1}]]".format(src, alt) elif tag == "hr": - self.zim_str += "-----\n" + # hr is self-closing – handle here only, NOT in beg/end dicts + self.zim_str += "\n-----\n" def to_zim(self, html_str, folder): self.folder = folder + _dbg('=== to_zim called, folder=' + str(folder), self._log) self.feed(html_str) - #return self.zim_str.strip() + ("\n\n" if self.zim_str.strip().endswith("|") else "") - return re.sub(r'\n\n+', "\n\n", self.zim_str).strip() + ("\n\n" if self.zim_str.strip().endswith("|") else "") + _dbg('=== feeding done, zim_str length=' + str(len(self.zim_str)), self._log) + result = re.sub(r'\n\n+', "\n\n", self.zim_str).strip() + ("\n\n" if self.zim_str.strip().endswith("|") else "") + _show_debug_terminal(self._log) + return result