Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow loading LaTeX user-defined macros from text files #27

Merged
merged 2 commits into from
Feb 15, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Contribute.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Local development
=================

1. Create a virtual environment and install packages via `pip` from `devrequirements.txt`
2. Add your tests to `test_math.py`
3. In the CLI run `python -m unittest discover -t ..`

16 changes: 14 additions & 2 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ the math output in the summary.
To restore math, [BeautifulSoup4](https://pypi.python.org/pypi/beautifulsoup4/4.4.0)
is used. If it is not installed, no summary processing will happen.

### Load custom LaTeX macros

If you use the same macros over and over, it's a good idea to not repeat yourself defining them in multiple Markdown or reStructuredText documents. What you can do instead is tell the plugin absolute paths for text files containing macro definitions.

If the same macro name has multiple definitions, the last one is used and a warning is printed to stdout.

See below in the Usage section for examples.

Usage
-----
### Templates
Expand Down Expand Up @@ -99,11 +107,15 @@ Requires [BeautifulSoup4](http://www.crummy.com/software/BeautifulSoup/bs4/doc/)
**Default Value**: `False`
* `message_style`: [string] This value controls the verbosity of the messages in the lower left-hand corner. Set it to `None` to eliminate all messages.
**Default Value**: normal
* `macros`: [list] each element of the list is a [string] containing the absolute path to a file with macro definitions.
**Default Value**: `[]`

#### Settings Examples
Make math render in blue and displaymath align to the left:
Make math render in blue, displaymath align to the left and load macros from `/home/user/latex-macros.tex`:

macros = ['/home/user/latex-macros.tex']
MATH_JAX = {'color': 'blue', 'align': 'left', 'macros': macros}

MATH_JAX = {'color':'blue','align':left}

Use the [color](http://docs.mathjax.org/en/latest/tex.html#color) and
[mhchem](http://docs.mathjax.org/en/latest/tex.html#mhchem) extensions:
Expand Down
2 changes: 1 addition & 1 deletion __init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
from .math import *
from .render_math import *
11 changes: 11 additions & 0 deletions devrequirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Jinja2==2.8
MarkupSafe==0.23
Pygments==2.1
Unidecode==0.04.19
blinker==1.4
docutils==0.12
feedgenerator==1.7
pelican==3.6.3
python-dateutil==2.4.2
pytz==2015.7
six==1.10.0
3 changes: 3 additions & 0 deletions latex-commands-example.tex
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
\newcommand{\pp}[2]{\frac{\partial #1}{\partial #2}}
\newcommand{\bb}{\pi R}
\newcommand{\bc}{\pi r}
2 changes: 1 addition & 1 deletion mathjax_script_template
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ if (!document.getElementById('mathjaxscript_pelican_#%@#$@#')) {{
mathjaxscript[(window.opera ? "innerHTML" : "text")] =
"MathJax.Hub.Config({{" +
" config: ['MMLorHTML.js']," +
" TeX: {{ extensions: ['AMSmath.js','AMSsymbols.js','noErrors.js','noUndefined.js'{tex_extensions}], equationNumbers: {{ autoNumber: 'AMS' }} }}," +
" TeX: {{ extensions: ['AMSmath.js','AMSsymbols.js','noErrors.js','noUndefined.js'{tex_extensions}], equationNumbers: {{ autoNumber: 'AMS' }}, Macros: {macros} }}," +
" jax: ['input/TeX','input/MathML','output/HTML-CSS']," +
" extensions: ['tex2jax.js','mml2jax.js','MathMenu.js','MathZoom.js']," +
" displayAlign: '"+ align +"'," +
Expand Down
125 changes: 124 additions & 1 deletion math.py → render_math.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
the math. See README for more details.
"""

import collections
import os
import sys

Expand Down Expand Up @@ -71,6 +72,7 @@ def process_settings(pelicanobj):
mathjax_settings['process_summary'] = BeautifulSoup is not None # will fix up summaries if math is cut off. Requires beautiful soup
mathjax_settings['force_tls'] = 'false' # will force mathjax to be served by https - if set as False, it will only use https if site is served using https
mathjax_settings['message_style'] = 'normal' # This value controls the verbosity of the messages in the lower left-hand corner. Set it to "none" to eliminate all messages
mathjax_settings['macros'] = '{}'

# Source for MathJax
mathjax_settings['source'] = "'//cdn.mathjax.org/mathjax/latest/MathJax.js?config=TeX-AMS-MML_HTMLorMML'"
Expand Down Expand Up @@ -192,8 +194,129 @@ def process_settings(pelicanobj):

mathjax_settings[key] = value

if key == 'macros':
text_lines = []
macros = parse_tex_macros(value)
for macro in macros:
if 'args' in macro.keys():
# number of arguments > 1
text_lines.append("{0}: ['{1}', {2}]".format(macro['name'], macro['definition'], macro['args']))
else:
text_lines.append("{0}: '{1}'".format(macro['name'], macro['definition']))
mathjax_settings[key] = '{' + ", ".join(text_lines) + '}'

return mathjax_settings

def _load_macro_definitions(*args):
"""Returns list of lines from files, use absolute path.

Example: [{'filename': '/home/user/defs.text', 'line_num': 1,
'def': '\newcommand{\circ}{2 \pi R}'}]"""
output_lines = []
for arg in args:
with open(arg, 'rt') as input_file:
lines = input_file.read().splitlines()
for index, value in enumerate(lines):
line_num = index + 1
line = {'filename': arg, 'line_num': line_num, 'def': value}
output_lines.append(line)
return output_lines

def _filter_duplicates(*macros):
"""Returns a modified copy of the input list of macros by keeping
only the last definition of each duplicate item. Also, if a macro is
defined multiple times, a warning is printed to stdout.
Unique items are left untouched.
"""
names = []
for macro in macros:
names.append(macro['name'])

seen = set()
# duplicate_indices contains the index of the first time an element appears
# more than once in names
duplicate_indices = [names.index(item)
for item, count in collections.Counter(names).items()
if count > 1]
unique_indices = [names.index(item)
for item, count in collections.Counter(names).items()
if count == 1]
if len(duplicate_indices) > 0:
duplicates = []
for i in duplicate_indices:
name = macros[i]['name']
duplicate = {'name': name, 'where':[]}
for j in macros:
if j['name'] == name:
duplicate['where'].append((j['line'], j['file']))
duplicates.append(duplicate)
exception_text = "WARNING: macros where defined more than once, the last definition is used\n"
for dup in duplicates:
exception_text += "Macro {} defined in\n".format(dup['name'].strip('\\'))
for place in dup['where']:
exception_text += "{}, line {}\n".format(place[1], place[0])
print(exception_text)
# I need the last definition for each duplicate definition
last_duplicated_indices = []
for i, v in enumerate(duplicate_indices):
all_indices = []
for j, name in enumerate(names):
if name == names[v]:
all_indices.append(j)
last = max(all_indices)
last_duplicated_indices.append(last)

filtered = [elem for i, elem in enumerate(macros)
if i in unique_indices + last_duplicated_indices]
return filtered

def parse_tex_macros(args):
# ogni arg è un file
macros = []
for arg in args:
lines = _load_macro_definitions(arg)
for line in lines:
macros.append(_parse_macro(line))
_filter_duplicates(*macros)
# remove line and file keys from temp_macros
# (added for debug in case of duplicates)
return [{k: v for k, v in elem.items()
if k in ['name', 'definition', 'args']}
for elem in macros]

def _parse_macro(arg):
"""Returns a macro from input raw text.

The TeX macro definition is read and translated to a
dictionary containing the name without backslash and the definition;
if arguments are present, their number is added too.

Backslashes in the definition are added in order to ensure the proper
form in the final html page.

Example:
> {'name': 'pd',
'definition': '\\\\\\\\frac{\\\\\\\\partial #1}{\\\\\\\\partial #2}',
'args': 2,
'file': '/home/user/commands.tex',
'line': 1}"""
splitted = arg['def'].split('{')
name_number = splitted[1].split('}')
name = name_number[0].strip('\\')
# for the definition, remove the last character from the last string which is }
# remember that strings are immutable objects in python
last_def_token = splitted[-1][:-1]
splitted_def = splitted[2:-1] + [last_def_token]
complete_def = '{'.join(splitted_def).replace('\\','\\\\\\\\')
final_command = {'line': arg['line_num'], 'file': arg['filename'],
'name': name,
'definition': complete_def}
if name_number[1]:
# the number of arguments is defined, therefore name_number[1] is not null string
args_number = name_number[1].lstrip('[').rstrip(']')
final_command['args'] = args_number
return final_command

def process_summary(article):
"""Ensures summaries are not cut off. Also inserts
mathjax script so that math will be rendered"""
Expand Down Expand Up @@ -251,7 +374,6 @@ def process_mathjax_script(mathjax_settings):
with open (os.path.dirname(os.path.realpath(__file__))
+ '/mathjax_script_template', 'r') as mathjax_script_template:
mathjax_template = mathjax_script_template.read()

return mathjax_template.format(**mathjax_settings)

def mathjax_for_markdown(pelicanobj, mathjax_script, mathjax_settings):
Expand Down Expand Up @@ -349,4 +471,5 @@ def process_rst_and_summaries(content_generators):
def register():
"""Plugin registration"""
signals.initialized.connect(pelican_init)
# repeated
signals.all_generators_finalized.connect(process_rst_and_summaries)
84 changes: 84 additions & 0 deletions test_math.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import os
import unittest
from render_math import parse_tex_macros, _parse_macro, _filter_duplicates

class TestParseMacros(unittest.TestCase):
def test_multiple_arguments(self):
"""Parse a definition with multiple arguments"""
text = r'\newcommand{\pp}[2]{\frac{ #1}{ #2} \cdot 2}'
line = {'filename': '/home/user/example.tex', 'line_num': 1, 'def':
text}
parsed = _parse_macro(line)
expected = {'name':'pp',
'definition': '\\\\\\\\frac{ #1}{ #2} \\\\\\\\cdot 2',
'args': '2',
'line': 1,
'file': '/home/user/example.tex'}
self.assertEqual(parsed, expected)

def test_no_arguments(self):
"""Parse a definition without arguments"""
text = r'\newcommand{\circ}{2 \pi R}'
line = {'filename': '/home/user/example.tex', 'line_num': 1, 'def':
text}
parsed = _parse_macro(line)
expected = {'name':'circ',
'definition': '2 \\\\\\\\pi R',
'line': 1,
'file': '/home/user/example.tex'
}
self.assertEqual(parsed, expected)

def test_repeated_definitions_same_file(self):
"""Last definition is used"""
text1 = r'2 \\\\\\\\pi R'
text2 = r'2 \\\\\\\\pi r'
common_file = '/home/user/example.tex'
def1 = {'name': 'circ', 'line': 1, 'definition': text1,
'file': common_file}
def2 = {'name': 'circ', 'line': 2, 'definition': text2,
'file': common_file}
expected = [{'name':'circ',
'definition': r'2 \\\\\\\\pi r',
'line': 2,
'file': '/home/user/example.tex'
}]
parsed = _filter_duplicates(def1, def2)
self.assertEqual(parsed, expected)

def test_repeated_definitions_different_files(self):
"""Last definition is used"""
text1 = r'2 \\\\\\\\pi R'
text2 = r'2 \\\\\\\\pi r'
file1 = '/home/user/example1.tex'
file2 = '/home/user/example2.tex'
def1 = {'name': 'circ', 'line': 1, 'definition': text1,
'file': file1}
def2 = {'name': 'circ', 'line': 1, 'definition': text2,
'file': file2}
expected = [{'name':'circ',
'definition': r'2 \\\\\\\\pi r',
'line': 1,
'file': '/home/user/example2.tex'
}]
parsed = _filter_duplicates(def1, def2)
self.assertEqual(parsed, expected)

def test_load_file(self):
cur_dir = os.path.split(os.path.realpath(__file__))[0]
test_fname = os.path.join(cur_dir, "latex-commands-example.tex")
parsed = parse_tex_macros([test_fname])
expected = [{'name': 'pp',
'definition': '\\\\\\\\frac{\\\\\\\\partial #1}{'
'\\\\\\\\partial #2}',
'args': '2'},
{'name': 'bb',
'definition': '\\\\\\\\pi R',},
{'name': 'bc',
'definition': '\\\\\\\\pi r',
}]
self.maxDiff = None
self.assertEqual(parsed, expected)

if __name__ == '__main__':
unittest.main()