Commit 28a1ca8a by Peter Baratta

Comment fixes

parent af1f8c1f
...@@ -11,15 +11,15 @@ import numpy ...@@ -11,15 +11,15 @@ import numpy
import scipy.constants import scipy.constants
import calcfunctions import calcfunctions
# Have numpy ignore errors on functions outside its domain # Have numpy ignore errors on functions outside its domain.
# See http://docs.scipy.org/doc/numpy/reference/generated/numpy.seterr.html # See http://docs.scipy.org/doc/numpy/reference/generated/numpy.seterr.html
# TODO worry about thread safety/changing a global setting
numpy.seterr(all='ignore') # Also: 'ignore', 'warn' (default), 'raise' numpy.seterr(all='ignore') # Also: 'ignore', 'warn' (default), 'raise'
from pyparsing import (Word, nums, Literal, from pyparsing import (
ZeroOrMore, MatchFirst, Word, Literal, CaselessLiteral, ZeroOrMore, MatchFirst, Optional, Forward,
Optional, Forward, Group, ParseResults, stringEnd, Suppress, Combine, alphas, nums, alphanums
CaselessLiteral, Group, ParseResults, )
stringEnd, Suppress, Combine, alphas, alphanums)
DEFAULT_FUNCTIONS = { DEFAULT_FUNCTIONS = {
'sin': numpy.sin, 'sin': numpy.sin,
...@@ -67,11 +67,11 @@ DEFAULT_VARIABLES = { ...@@ -67,11 +67,11 @@ DEFAULT_VARIABLES = {
} }
# We eliminated the following extreme suffixes: # We eliminated the following extreme suffixes:
# P (1e15), E (1e18), Z (1e21), Y (1e24), # P (1e15), E (1e18), Z (1e21), Y (1e24),
# f (1e-15), a (1e-18), z (1e-21), y (1e-24) # f (1e-15), a (1e-18), z (1e-21), y (1e-24)
# since they're rarely used, and potentially # since they're rarely used, and potentially confusing.
# confusing. They may also conflict with variables if we ever allow e.g. # They may also conflict with variables if we ever allow e.g.
# 5R instead of 5*R # 5R instead of 5*R
SUFFIXES = { SUFFIXES = {
'%': 0.01, 'k': 1e3, 'M': 1e6, 'G': 1e9, 'T': 1e12, '%': 0.01, 'k': 1e3, 'M': 1e6, 'G': 1e9, 'T': 1e12,
'c': 1e-2, 'm': 1e-3, 'u': 1e-6, 'n': 1e-9, 'p': 1e-12 'c': 1e-2, 'm': 1e-3, 'u': 1e-6, 'n': 1e-9, 'p': 1e-12
...@@ -80,14 +80,14 @@ SUFFIXES = { ...@@ -80,14 +80,14 @@ SUFFIXES = {
class UndefinedVariable(Exception): class UndefinedVariable(Exception):
""" """
Indicate the student input of a variable which was unused by the instructor Indicate when a student inputs a variable which was not expected.
""" """
pass pass
def lower_dict(input_dict): def lower_dict(input_dict):
""" """
Convert all keys in a dictionary to lowercase; keep their original values Convert all keys in a dictionary to lowercase; keep their original values.
Keep in mind that it is possible (but not useful?) to define different Keep in mind that it is possible (but not useful?) to define different
variables that have the same lowercase representation. It would be hard to variables that have the same lowercase representation. It would be hard to
...@@ -102,7 +102,7 @@ def lower_dict(input_dict): ...@@ -102,7 +102,7 @@ def lower_dict(input_dict):
def super_float(text): def super_float(text):
""" """
Like float, but with si extensions. 1k goes to 1000 Like float, but with SI extensions. 1k goes to 1000.
""" """
if text[-1] in SUFFIXES: if text[-1] in SUFFIXES:
return float(text[:-1]) * SUFFIXES[text[-1]] return float(text[:-1]) * SUFFIXES[text[-1]]
...@@ -112,10 +112,10 @@ def super_float(text): ...@@ -112,10 +112,10 @@ def super_float(text):
def eval_number(parse_result): def eval_number(parse_result):
""" """
Create a float out of its string parts Create a float out of its string parts.
e.g. [ '7', '.', '13' ] -> 7.13 e.g. [ '7.13', 'e', '3' ] -> 7130
Calls super_float above Calls super_float above.
""" """
return super_float("".join(parse_result)) return super_float("".join(parse_result))
...@@ -132,28 +132,30 @@ def eval_atom(parse_result): ...@@ -132,28 +132,30 @@ def eval_atom(parse_result):
def eval_power(parse_result): def eval_power(parse_result):
""" """
Take a list of numbers and exponentiate them, right to left Take a list of numbers and exponentiate them, right to left.
e.g. [ 3, 2, 3 ] (which is 3^2^3 = 3^(2^3)) -> 6561 e.g. [ 2, 3, 2 ] -> 2^3^2 = 2^(3^2) -> 512
(not to be interpreted (2^3)^2 = 64)
""" """
# `reduce` will go from left to right; reverse the list.
parse_result = reversed( parse_result = reversed(
[k for k in parse_result [k for k in parse_result
if isinstance(k, numbers.Number)] if isinstance(k, numbers.Number)] # Ignore the '^' marks.
) )
# The result of an exponentiation is called a power # Having reversed it, raise `b` to the power of `a`.
power = reduce(lambda a, b: b ** a, parse_result) power = reduce(lambda a, b: b ** a, parse_result)
return power return power
def eval_parallel(parse_result): def eval_parallel(parse_result):
""" """
Compute numbers according to the parallel resistors operator Compute numbers according to the parallel resistors operator.
BTW it is commutative. Its formula is given by BTW it is commutative. Its formula is given by
out = 1 / (1/in1 + 1/in2 + ...) out = 1 / (1/in1 + 1/in2 + ...)
e.g. [ 1, 2 ] -> 2/3 e.g. [ 1, 2 ] -> 2/3
Return NaN if there is a zero among the inputs Return NaN if there is a zero among the inputs.
""" """
if len(parse_result) == 1: if len(parse_result) == 1:
return parse_result[0] return parse_result[0]
...@@ -166,11 +168,11 @@ def eval_parallel(parse_result): ...@@ -166,11 +168,11 @@ def eval_parallel(parse_result):
def eval_sum(parse_result): def eval_sum(parse_result):
""" """
Add the inputs Add the inputs, keeping in mind their sign.
[ 1, '+', 2, '-', 3 ] -> 0 [ 1, '+', 2, '-', 3 ] -> 0
Allow a leading + or - Allow a leading + or -.
""" """
total = 0.0 total = 0.0
current_op = operator.add current_op = operator.add
...@@ -186,7 +188,7 @@ def eval_sum(parse_result): ...@@ -186,7 +188,7 @@ def eval_sum(parse_result):
def eval_product(parse_result): def eval_product(parse_result):
""" """
Multiply the inputs Multiply the inputs.
[ 1, '*', 2, '/', 3 ] -> 0.66 [ 1, '*', 2, '/', 3 ] -> 0.66
""" """
...@@ -220,27 +222,27 @@ def add_defaults(variables, functions, case_sensitive): ...@@ -220,27 +222,27 @@ def add_defaults(variables, functions, case_sensitive):
def evaluator(variables, functions, math_expr, case_sensitive=False): def evaluator(variables, functions, math_expr, case_sensitive=False):
""" """
Evaluate an expression; that is, take a string of math and return a float Evaluate an expression; that is, take a string of math and return a float.
-Variables are passed as a dictionary from string to value. They must be -Variables are passed as a dictionary from string to value. They must be
python numbers python numbers.
-Unary functions are passed as a dictionary from string to function. -Unary functions are passed as a dictionary from string to function.
""" """
# No need to go further # No need to go further.
if math_expr.strip() == "": if math_expr.strip() == "":
return float('nan') return float('nan')
# Parse tree # Parse the tree.
thing = ParseAugmenter(math_expr, case_sensitive) thing = ParseAugmenter(math_expr, case_sensitive)
thing.parse_algebra() thing.parse_algebra()
# Get our variables together # Get our variables together.
all_variables, all_functions = add_defaults(variables, functions, case_sensitive) all_variables, all_functions = add_defaults(variables, functions, case_sensitive)
# ...and check them # ...and check them
thing.check_variables(all_variables, all_functions) thing.check_variables(all_variables, all_functions)
# Create a recursion to evaluate the tree # Create a recursion to evaluate the tree.
if case_sensitive: if case_sensitive:
casify = lambda x: x casify = lambda x: x
else: else:
...@@ -262,17 +264,17 @@ def evaluator(variables, functions, math_expr, case_sensitive=False): ...@@ -262,17 +264,17 @@ def evaluator(variables, functions, math_expr, case_sensitive=False):
class ParseAugmenter(object): class ParseAugmenter(object):
""" """
Holds the data for a particular parse Holds the data for a particular parse.
Holds the `math_expr` and `case_sensitive` so they needn't be passed around Retains the `math_expr` and `case_sensitive` so they needn't be passed
method to method. around method to method.
Eventually holds the parse tree and sets of variables as well. Eventually holds the parse tree and sets of variables as well.
""" """
def __init__(self, math_expr, case_sensitive=False): def __init__(self, math_expr, case_sensitive=False):
""" """
Create the ParseAugmenter for a given math expression string. Create the ParseAugmenter for a given math expression string.
Have the parsing done later, when called like OBJ.parse_algebra() Do the parsing later, when called like `OBJ.parse_algebra()`.
""" """
self.case_sensitive = case_sensitive self.case_sensitive = case_sensitive
self.math_expr = math_expr self.math_expr = math_expr
...@@ -282,11 +284,11 @@ class ParseAugmenter(object): ...@@ -282,11 +284,11 @@ class ParseAugmenter(object):
def make_variable_parse_action(self): def make_variable_parse_action(self):
""" """
Create a wrapper to store variables as they are parsed Create a wrapper to store variables as they are parsed.
""" """
def vpa(tokens): def vpa(tokens):
""" """
When a variable is recognized, store its correct form in `variables_used` When a variable is recognized, store its correct form in `variables_used`.
""" """
if self.case_sensitive: if self.case_sensitive:
varname = tokens[0][0] varname = tokens[0][0]
...@@ -297,11 +299,11 @@ class ParseAugmenter(object): ...@@ -297,11 +299,11 @@ class ParseAugmenter(object):
def make_function_parse_action(self): def make_function_parse_action(self):
""" """
Create a wrapper to store functions as they are parsed Create a wrapper to store functions as they are parsed.
""" """
def fpa(tokens): def fpa(tokens):
""" """
When a function is recognized, store its correct form in `variables_used` When a function is recognized, store its correct form in `variables_used`.
""" """
if self.case_sensitive: if self.case_sensitive:
varname = tokens[0][0] varname = tokens[0][0]
...@@ -314,7 +316,7 @@ class ParseAugmenter(object): ...@@ -314,7 +316,7 @@ class ParseAugmenter(object):
""" """
Parse an algebraic expression into a tree. Parse an algebraic expression into a tree.
Store a `pyparsing.ParseResult` in self.tree with proper groupings to Store a `pyparsing.ParseResult` in `self.tree` with proper groupings to
reflect parenthesis and order of operations. Leave all operators in the reflect parenthesis and order of operations. Leave all operators in the
tree and do not parse any strings of numbers into their float versions. tree and do not parse any strings of numbers into their float versions.
...@@ -325,10 +327,10 @@ class ParseAugmenter(object): ...@@ -325,10 +327,10 @@ class ParseAugmenter(object):
# 0.33 or 7 or .34 or 16. # 0.33 or 7 or .34 or 16.
number_part = Word(nums) number_part = Word(nums)
inner_number = (number_part + Optional("." + Optional(number_part))) | ("." + number_part) inner_number = (number_part + Optional("." + Optional(number_part))) | ("." + number_part)
# pyparsing allows spaces between tokens--`Combine` prevents that # pyparsing allows spaces between tokens--`Combine` prevents that.
inner_number = Combine(inner_number) inner_number = Combine(inner_number)
# SI suffixes and percent # SI suffixes and percent.
number_suffix = MatchFirst(Literal(k) for k in SUFFIXES.keys()) number_suffix = MatchFirst(Literal(k) for k in SUFFIXES.keys())
# 0.33k or 17 # 0.33k or 17
...@@ -340,11 +342,11 @@ class ParseAugmenter(object): ...@@ -340,11 +342,11 @@ class ParseAugmenter(object):
) )
number = number("number") number = number("number")
# Predefine recursive variables # Predefine recursive variables.
expr = Forward() expr = Forward()
# Handle variables passed in. They must start with letters/underscores # Handle variables passed in. They must start with letters/underscores
# and may contain numbers afterward # and may contain numbers afterward.
inner_varname = Word(alphas + "_", alphanums + "_") inner_varname = Word(alphas + "_", alphanums + "_")
varname = Group(inner_varname)("variable") varname = Group(inner_varname)("variable")
varname.setParseAction(self.make_variable_parse_action()) varname.setParseAction(self.make_variable_parse_action())
...@@ -356,7 +358,7 @@ class ParseAugmenter(object): ...@@ -356,7 +358,7 @@ class ParseAugmenter(object):
atom = number | function | varname | "(" + expr + ")" atom = number | function | varname | "(" + expr + ")"
atom = Group(atom)("atom") atom = Group(atom)("atom")
# Do the following in the correct order to preserve order of operation # Do the following in the correct order to preserve order of operation.
pow_term = atom + ZeroOrMore("^" + atom) pow_term = atom + ZeroOrMore("^" + atom)
pow_term = Group(pow_term)("power") pow_term = Group(pow_term)("power")
...@@ -369,7 +371,7 @@ class ParseAugmenter(object): ...@@ -369,7 +371,7 @@ class ParseAugmenter(object):
sum_term = Optional(plus_minus) + prod_term + ZeroOrMore(plus_minus + prod_term) # -5 + 4 - 3 sum_term = Optional(plus_minus) + prod_term + ZeroOrMore(plus_minus + prod_term) # -5 + 4 - 3
sum_term = Group(sum_term)("sum") sum_term = Group(sum_term)("sum")
# Finish the recursion # Finish the recursion.
expr << sum_term # pylint: disable=W0104 expr << sum_term # pylint: disable=W0104
self.tree = (expr + stringEnd).parseString(self.math_expr)[0] self.tree = (expr + stringEnd).parseString(self.math_expr)[0]
...@@ -379,12 +381,12 @@ class ParseAugmenter(object): ...@@ -379,12 +381,12 @@ class ParseAugmenter(object):
`handle_actions` is a dictionary of node names (e.g. 'product', 'sum', `handle_actions` is a dictionary of node names (e.g. 'product', 'sum',
etc&) to functions. These functions are of the following form: etc&) to functions. These functions are of the following form:
-input: a list of processed child nodes. If it includes any terminal -input: a list of processed child nodes. If it includes any terminal
nodes in the list, they will be given as their processed forms also. nodes in the list, they will be given as their processed forms also.
-output: whatever to be passed to the level higher, and what to -output: whatever to be passed to the level higher, and what to
return for the final node. return for the final node.
`handle_terminal` is a function that takes in a token and returns a `handle_terminal` is a function that takes in a token and returns a
processed form. Leaving it as `None` just keeps it as the identity. processed form. Leaving it as `None` just leaves them as strings.
""" """
def handle_node(node): def handle_node(node):
""" """
...@@ -394,6 +396,7 @@ class ParseAugmenter(object): ...@@ -394,6 +396,7 @@ class ParseAugmenter(object):
feed it the output of `handle_node` for each child node. feed it the output of `handle_node` for each child node.
""" """
if not isinstance(node, ParseResults): if not isinstance(node, ParseResults):
# Then it is a terminal node.
if handle_terminal is None: if handle_terminal is None:
return node return node
else: else:
...@@ -407,7 +410,7 @@ class ParseAugmenter(object): ...@@ -407,7 +410,7 @@ class ParseAugmenter(object):
handled_kids = [handle_node(k) for k in node] handled_kids = [handle_node(k) for k in node]
return action(handled_kids) return action(handled_kids)
# Find the value of the entire tree # Find the value of the entire tree.
return handle_node(self.tree) return handle_node(self.tree)
def check_variables(self, valid_variables, valid_functions): def check_variables(self, valid_variables, valid_functions):
...@@ -416,7 +419,7 @@ class ParseAugmenter(object): ...@@ -416,7 +419,7 @@ class ParseAugmenter(object):
Otherwise, raise an UndefinedVariable containing all bad variables. Otherwise, raise an UndefinedVariable containing all bad variables.
""" """
# Test that `used_vars` is a subset of `all_vars`; also do functions # Test that `used_vars` is a subset of `all_vars`; also do functions.
if not (self.variables_used.issubset(valid_variables) and if not (self.variables_used.issubset(valid_variables) and
self.functions_used.issubset(valid_functions)): self.functions_used.issubset(valid_functions)):
bad_vars = self.variables_used.difference(valid_variables) bad_vars = self.variables_used.difference(valid_variables)
......
...@@ -5,7 +5,7 @@ That is, given a math string, parse it and render each branch of the result, ...@@ -5,7 +5,7 @@ That is, given a math string, parse it and render each branch of the result,
always returning valid latex. always returning valid latex.
Because intermediate values of the render contain more data than simply the Because intermediate values of the render contain more data than simply the
string of latex, store it in a custom class `LatexRendered` string of latex, store it in a custom class `LatexRendered`.
""" """
from calc import ParseAugmenter, add_defaults, SUFFIXES from calc import ParseAugmenter, add_defaults, SUFFIXES
...@@ -16,26 +16,26 @@ class LatexRendered(object): ...@@ -16,26 +16,26 @@ class LatexRendered(object):
Data structure to hold a typeset representation of some math. Data structure to hold a typeset representation of some math.
Fields: Fields:
-`latex` is a generated, valid latex string (as if it were standalone) -`latex` is a generated, valid latex string (as if it were standalone).
-`sans_parens` is usually the same as `latex` except without the outermost -`sans_parens` is usually the same as `latex` except without the outermost
parens (if applicable) parens (if applicable).
-`tall` is a boolean representing if the latex has any elements extending -`tall` is a boolean representing if the latex has any elements extending
above or below a normal height, specifically things of the form 'a^b' and above or below a normal height, specifically things of the form 'a^b' and
'\frac{a}{b}'. This affects the height of wrapping parenthesis. '\frac{a}{b}'. This affects the height of wrapping parenthesis.
""" """
def __init__(self, latex, parens=None, tall=False): def __init__(self, latex, parens=None, tall=False):
""" """
Instantiate with the latex representing the math Instantiate with the latex representing the math.
Optionally include parenthesis to wrap around it and the height. Optionally include parenthesis to wrap around it and the height.
`parens` must be one of '(', '[' or '{' `parens` must be one of '(', '[' or '{'.
`tall` is a boolean (see note above) `tall` is a boolean (see note above).
""" """
self.latex = latex self.latex = latex
self.sans_parens = latex self.sans_parens = latex
self.tall = tall self.tall = tall
# generate parens and overwrite self.latex # Generate parens and overwrite `self.latex`.
if parens is not None: if parens is not None:
left_parens = parens left_parens = parens
if left_parens == '{': if left_parens == '{':
...@@ -107,6 +107,7 @@ def function_closure(functions, casify): ...@@ -107,6 +107,7 @@ def function_closure(functions, casify):
if casify(fname) not in functions: if casify(fname) not in functions:
pass pass
# Wrap the input of the function with parens or braces.
inner = children[1].latex inner = children[1].latex
if fname == "sqrt": if fname == "sqrt":
inner = u"{{{expr}}}".format(expr=inner) inner = u"{{{expr}}}".format(expr=inner)
...@@ -116,6 +117,7 @@ def function_closure(functions, casify): ...@@ -116,6 +117,7 @@ def function_closure(functions, casify):
else: else:
inner = u"({expr})".format(expr=inner) inner = u"({expr})".format(expr=inner)
# Correctly format the name of the function.
if fname == "sqrt": if fname == "sqrt":
fname = ur"\sqrt" fname = ur"\sqrt"
elif fname == "log10": elif fname == "log10":
...@@ -125,8 +127,10 @@ def function_closure(functions, casify): ...@@ -125,8 +127,10 @@ def function_closure(functions, casify):
else: else:
fname = ur"\text{{{fname}}}".format(fname=fname) fname = ur"\text{{{fname}}}".format(fname=fname)
# Put it together.
latex = fname + inner latex = fname + inner
return LatexRendered(latex, tall=children[1].tall) return LatexRendered(latex, tall=children[1].tall)
# Return the function within the closure.
return render_function return render_function
...@@ -134,7 +138,8 @@ def render_power(children): ...@@ -134,7 +138,8 @@ def render_power(children):
""" """
Combine powers so that the latex is wrapped in curly braces correctly. Combine powers so that the latex is wrapped in curly braces correctly.
If you have 'a^(b+c)' don't include that last set of parens ('a^{b+c}'). Also, if you have 'a^(b+c)' don't include that last set of parens:
'a^{b+c}' is correct, whereas 'a^{(b+c)}' is extraneous.
""" """
if len(children) == 1: if len(children) == 1:
return children[0] return children[0]
...@@ -149,7 +154,7 @@ def render_power(children): ...@@ -149,7 +154,7 @@ def render_power(children):
def render_parallel(children): def render_parallel(children):
""" """
Simply combine elements with a double vertical line. Simply join the child nodes with a double vertical line.
""" """
children_latex = [k.latex for k in children if k.latex != "||"] children_latex = [k.latex for k in children if k.latex != "||"]
latex = r"\|".join(children_latex) latex = r"\|".join(children_latex)
...@@ -161,7 +166,7 @@ def render_frac(numerator, denominator): ...@@ -161,7 +166,7 @@ def render_frac(numerator, denominator):
r""" r"""
Given a list of elements in the numerator and denominator, return a '\frac' Given a list of elements in the numerator and denominator, return a '\frac'
Avoid parens if there is only thing in that part Avoid parens if they are unnecessary (i.e. the only thing in that part).
""" """
if len(numerator) == 1: if len(numerator) == 1:
num_latex = numerator[0].sans_parens num_latex = numerator[0].sans_parens
...@@ -181,9 +186,15 @@ def render_product(children): ...@@ -181,9 +186,15 @@ def render_product(children):
r""" r"""
Format products and division nicely. Format products and division nicely.
That is, group bunches of adjacent, equal operators. For every time it Group bunches of adjacent, equal operators. Every time it switches from
switches from numerator to denominator, call `render_frac`. Join these denominator to the next numerator, call `render_frac`. Join these groupings
groupings by '\cdot's. together with '\cdot's, ending on a numerator if needed.
Examples: (`children` is formed indirectly by the string on the left)
'a*b' -> 'a\cdot b'
'a/b' -> '\frac{a}{b}'
'a*b/c/d' -> '\frac{a\cdot b}{c\cdot d}'
'a/b*c/d*e' -> '\frac{a}{b}\cdot \frac{c}{d}\cdot e'
""" """
position = "numerator" # or denominator position = "numerator" # or denominator
fraction_mode_ever = False fraction_mode_ever = False
...@@ -194,29 +205,33 @@ def render_product(children): ...@@ -194,29 +205,33 @@ def render_product(children):
for kid in children: for kid in children:
if position == "numerator": if position == "numerator":
if kid.latex == "*": if kid.latex == "*":
pass pass # Don't explicitly add the '\cdot' yet.
elif kid.latex == "/": elif kid.latex == "/":
# Switch to denominator mode.
fraction_mode_ever = True fraction_mode_ever = True
position = "denominator" position = "denominator"
else: else:
numerator.append(kid) numerator.append(kid)
else: else:
if kid.latex == "*": if kid.latex == "*":
# render the current fraction and add it to the latex # Switch back to numerator mode.
# First, render the current fraction and add it to the latex.
latex += render_frac(numerator, denominator) + r"\cdot " latex += render_frac(numerator, denominator) + r"\cdot "
# reset back to beginning state # Reset back to beginning state
position = "numerator" position = "numerator"
numerator = [] numerator = []
denominator = [] denominator = []
elif kid.latex == "/": elif kid.latex == "/":
pass pass # Don't explicitly add a '\frac' yet.
else: else:
denominator.append(kid) denominator.append(kid)
# Add the fraction/numerator that we ended on.
if position == "denominator": if position == "denominator":
latex += render_frac(numerator, denominator) latex += render_frac(numerator, denominator)
else: else:
# We ended on a numerator--act like normal multiplication.
num_latex = r"\cdot ".join(k.latex for k in numerator) num_latex = r"\cdot ".join(k.latex for k in numerator)
latex += num_latex latex += num_latex
...@@ -226,7 +241,7 @@ def render_product(children): ...@@ -226,7 +241,7 @@ def render_product(children):
def render_sum(children): def render_sum(children):
""" """
Combine elements, including their operators. Concatenate elements, including the operators.
""" """
children_latex = [k.latex for k in children] children_latex = [k.latex for k in children]
latex = "".join(children_latex) latex = "".join(children_latex)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment