diff --git a/test/classes.ipynb b/test/classes.ipynb new file mode 100644 index 0000000..86255e1 --- /dev/null +++ b/test/classes.ipynb @@ -0,0 +1,125 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + " \n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import ziamath as zm\n", + "from pathlib import Path\n", + "zm.config.svg2 = False\n", + "zm.config.math.fontsize = 50\n", + "zm.config.svg_classes = True\n", + "zm.config.svg_style = r'''\n", + ".mrow { fill:blue }\n", + ".mn { fill:magenta ; }\n", + ".mo { fill:yellow ; stroke:grey; stroke-width:1px;}\n", + ".mi { fill:url(#grad1) ; stroke:darkslategrey; stroke-width:1px;}\n", + ".background { fill:violet ; stroke:none}\n", + ".hline {fill:green}\n", + "'''\n", + "zm.config.svg_defs = r'''\n", + " \n", + " \n", + " \n", + " \n", + "'''\n", + "mml = '''\n", + "\n", + "\n", + " \n", + " (\n", + " \n", + " p\n", + " 2\n", + " \n", + " )\n", + " \n", + " \n", + " x\n", + " 2\n", + " \n", + " \n", + " y\n", + " \n", + " p\n", + " -\n", + " 2\n", + " \n", + " \n", + " -\n", + " \n", + " 1\n", + " \n", + " 1\n", + " -\n", + " x\n", + " \n", + " \n", + " \n", + " 1\n", + " \n", + " 1\n", + " -\n", + " \n", + " x\n", + " 2\n", + " \n", + " \n", + " \n", + "\n", + "\n", + "'''\n", + "m = zm.Math(mml)\n", + "# Path(f\"/tmp/math.svg\").write_text(zm.Math(mml).svg())\n", + "m" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "env2", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/ziamath/config.py b/ziamath/config.py index 7cefe8a..1e3979b 100644 --- a/ziamath/config.py +++ b/ziamath/config.py @@ -1,4 +1,5 @@ -''' Global configuration options ''' +"""Global configuration options""" + from typing import Optional, Callable from dataclasses import dataclass, field @@ -22,19 +23,19 @@ def off(self): @dataclass class TextStyle: textfont: Optional[str] = None - variant: str = 'serif' + variant: str = "serif" fontsize: float = 24 - color: str = 'black' + color: str = "black" linespacing: float = 1 @dataclass class MathStyle: mathfont: Optional[str] = None - variant: str = '' + variant: str = "" fontsize: float = 24 - color: str = 'black' - background: str = 'none' + color: str = "" + background: str = "none" bold_font: Optional[str] = None italic_font: Optional[str] = None bolditalic_font: Optional[str] = None @@ -42,22 +43,23 @@ class MathStyle: @dataclass class NumberingStyle: - ''' Style for equation numbers - - Args: - autonumber: Automatically number all equations - format: String formatter for equation numbers - format_func: Function to return a formatted equation label - columnwidth: Width of column or page. Equation numbers - right-aligned with the columnwidth. - ''' + """Style for equation numbers + + Args: + autonumber: Automatically number all equations + format: String formatter for equation numbers + format_func: Function to return a formatted equation label + columnwidth: Width of column or page. Equation numbers + right-aligned with the columnwidth. + """ + autonumber: bool = False - format: str = '({0})' + format: str = "({0})" format_func: Optional[Callable] = None - columnwidth: str = '6.5in' + columnwidth: str = "6.5in" def getlabel(self, i): - ''' Get equation label for equation number i''' + """Get equation label for equation number i""" if self.format_func: return self.format_func(i) return self.format.format(i) @@ -65,25 +67,29 @@ def getlabel(self, i): @dataclass class Config: - ''' Global configuration options for Ziamath - - Attributes - ---------- - minsizefraction: Smallest allowed text size, as fraction of - base size, for text such as subscripts and superscripts - debug: Debug mode, draws bounding boxes around - svg2: Use SVG2.0. Disable for better browser compatibility, - at the expense of SVG size - precision: SVG decimal precision for coordinates - decimal_separator: Use `.` or `,` as decimal separator. (only - affects Latex math) - ''' + """Global configuration options for Ziamath + + Attributes + ---------- + minsizefraction: Smallest allowed text size, as fraction of + base size, for text such as subscripts and superscripts + debug: Debug mode, draws bounding boxes around + svg2: Use SVG2.0. Disable for better browser compatibility, + at the expense of SVG size + precision: SVG decimal precision for coordinates + decimal_separator: Use `.` or `,` as decimal separator. (only + affects Latex math) + """ + math: MathStyle = field(default_factory=MathStyle) text: TextStyle = field(default_factory=TextStyle) - minsizefraction: float = .3 - decimal_separator = '.' + minsizefraction: float = 0.3 + decimal_separator = "." debug: DebugConfig = field(default_factory=DebugConfig) numbering: NumberingStyle = field(default_factory=NumberingStyle) + svg_classes: bool = False + svg_style: str = "" + svg_defs: str = "" @property def svg2(self) -> bool: diff --git a/ziamath/drawable.py b/ziamath/drawable.py index c6988df..c54ef52 100644 --- a/ziamath/drawable.py +++ b/ziamath/drawable.py @@ -10,212 +10,250 @@ class Drawable: - ''' Base class for drawable nodes ''' - mtag = 'drawable' + """Base class for drawable nodes""" + + mtag = "drawable" nodes: list[Drawable] = [] def __init__(self): self.bbox = BBox(0, 0, 0, 0) def firstglyph(self) -> Optional[SimpleGlyph]: - ''' Get the first glyph in this node ''' + """Get the first glyph in this node""" return None def lastglyph(self) -> Optional[SimpleGlyph]: - ''' Get the last glyph in this node ''' + """Get the last glyph in this node""" return None def lastchar(self) -> Optional[str]: - ''' Get the last character in this node ''' + """Get the last character in this node""" return None def xadvance(self) -> float: - ''' X-advance for the glyph. Usually bbox.xmax ''' + """X-advance for the glyph. Usually bbox.xmax""" return self.bbox.xmax def draw(self, x: float, y: float, svg: ET.Element) -> tuple[float, float]: - ''' Draw the element. Must be subclassed. ''' + """Draw the element. Must be subclassed.""" raise NotImplementedError class Glyph(Drawable): - ''' A single glyph - - Args: - glyph: The glyph to draw - char: unicode character represented by the glyph - size: point size - style: font MathStyle - ''' - def __init__(self, glyph: SimpleGlyph, char: str, size: float, - style: Optional[MathStyle] = None, **kwargs): + """A single glyph + + Args: + glyph: The glyph to draw + char: unicode character represented by the glyph + size: point size + style: font MathStyle + """ + + def __init__( + self, + glyph: SimpleGlyph, + char: str, + size: float, + style: Optional[MathStyle] = None, + **kwargs, + ): super().__init__() self.glyph = glyph self.char = char self.size = size - self.phantom = kwargs.get('phantom', False) + self.phantom = kwargs.get("phantom", False) self.style = style if style else MathStyle() self._funits_to_pts = self.size / self.glyph.font.info.layout.unitsperem self.bbox = BBox( self.funit_to_points(self.glyph.path.bbox.xmin), self.funit_to_points(self.glyph.path.bbox.xmax), self.funit_to_points(self.glyph.path.bbox.ymin), - self.funit_to_points(self.glyph.path.bbox.ymax)) - + self.funit_to_points(self.glyph.path.bbox.ymax), + ) + def funit_to_points(self, value: float) -> float: - ''' Convert font units to SVG points ''' + """Convert font units to SVG points""" return value * self._funits_to_pts def firstglyph(self) -> Optional[SimpleGlyph]: - ''' Get the first glyph in this node ''' + """Get the first glyph in this node""" return self.glyph def lastglyph(self) -> Optional[SimpleGlyph]: - ''' Get the last glyph in this node ''' + """Get the last glyph in this node""" return self.glyph def lastchar(self) -> Optional[str]: - ''' Get the last character in this node ''' + """Get the last character in this node""" return self.char def xadvance(self) -> float: - ''' X-advance for the glyph. Usually bbox.xmax ''' + """X-advance for the glyph. Usually bbox.xmax""" return self.funit_to_points(self.glyph.advance()) def draw(self, x: float, y: float, svg: ET.Element) -> tuple[float, float]: - ''' Draw the node on the SVG - - Args: - x: Horizontal position in SVG coordinates - y: Vertical position in SVG coordinates - svg: SVG drawing as XML - ''' - symbols = svg.findall('symbol') - symids = [sym.attrib.get('id') for sym in symbols] + """Draw the node on the SVG + + Args: + x: Horizontal position in SVG coordinates + y: Vertical position in SVG coordinates + svg: SVG drawing as XML + """ + symbols = svg.findall("symbol") + symids = [sym.attrib.get("id") for sym in symbols] if self.glyph.id not in symids and config.svg2: svg.append(self.glyph.svgsymbol()) if not self.phantom: path = self.glyph.place(x, y, self.size) if path is not None: svg.append(path) + if config.svg_classes: + pass if self.style.mathcolor and len(svg) > 0: - svg[-1].set('fill', str(self.style.mathcolor)) + svg[-1].set("fill", str(self.style.mathcolor)) x += self.funit_to_points(self.glyph.advance()) return x, y class HLine(Drawable): - ''' Horizontal Line. ''' - def __init__(self, length: float, lw: float, - style: Optional[MathStyle] = None, **kwargs): + """Horizontal Line.""" + + def __init__( + self, length: float, lw: float, style: Optional[MathStyle] = None, **kwargs + ): super().__init__() self.length = length self.lw = lw - self.phantom = kwargs.get('phantom', False) - self.bbox = BBox(0, self.length, -self.lw/2, self.lw/2) + self.phantom = kwargs.get("phantom", False) + self.bbox = BBox(0, self.length, -self.lw / 2, self.lw / 2) self.style = style if style else MathStyle() def draw(self, x: float, y: float, svg: ET.Element) -> tuple[float, float]: - ''' Draw the node on the SVG + """Draw the node on the SVG - Args: - x: Horizontal position in SVG coordinates - y: Vertical position in SVG coordinates - svg: SVG drawing as XML - ''' + Args: + x: Horizontal position in SVG coordinates + y: Vertical position in SVG coordinates + svg: SVG drawing as XML + """ if not self.phantom: # Use rectangle so it can change color with 'fill' attribute # and not mess up glyphs with 'stroke' attribute - bar = ET.SubElement(svg, 'rect') - bar.attrib['x'] = fmt(x) - bar.attrib['y'] = fmt(y) - bar.attrib['width'] = fmt(self.length) - bar.attrib['height'] = fmt(self.lw) + bar = ET.SubElement(svg, "rect") + bar.attrib["x"] = fmt(x) + bar.attrib["y"] = fmt(y) + bar.attrib["width"] = fmt(self.length) + bar.attrib["height"] = fmt(self.lw) + if config.svg_classes: + bar.set("class", "hline") if self.style.mathcolor: - bar.attrib['fill'] = str(self.style.mathcolor) - return x+self.length, y + bar.attrib["fill"] = str(self.style.mathcolor) + return x + self.length, y class VLine(Drawable): - ''' Vertical Line. ''' - def __init__(self, height: float, lw: float, - style: Optional[MathStyle] = None, **kwargs): + """Vertical Line.""" + + def __init__( + self, height: float, lw: float, style: Optional[MathStyle] = None, **kwargs + ): super().__init__() self.height = height self.lw = lw - self.phantom = kwargs.get('phantom', False) + self.phantom = kwargs.get("phantom", False) self.bbox = BBox(0, self.lw, 0, self.height) self.style = style if style else MathStyle() def draw(self, x: float, y: float, svg: ET.Element) -> tuple[float, float]: - ''' Draw the node on the SVG + """Draw the node on the SVG - Args: - x: Horizontal position in SVG coordinates - y: Vertical position in SVG coordinates - svg: SVG drawing as XML - ''' + Args: + x: Horizontal position in SVG coordinates + y: Vertical position in SVG coordinates + svg: SVG drawing as XML + """ if not self.phantom: # Use rectangle so it can change color with 'fill' attribute # and not mess up glyphs with 'stroke' attribute - bar = ET.SubElement(svg, 'rect') - bar.attrib['x'] = fmt(x-self.lw/2) - bar.attrib['y'] = fmt(y) - bar.attrib['width'] = fmt(self.lw) - bar.attrib['height'] = fmt(self.height) + bar = ET.SubElement(svg, "rect") + bar.attrib["x"] = fmt(x - self.lw / 2) + bar.attrib["y"] = fmt(y) + bar.attrib["width"] = fmt(self.lw) + bar.attrib["height"] = fmt(self.height) + if config.svg_classes: + bar.set("class", "vline") if self.style.mathcolor: - bar.attrib['fill'] = str(self.style.mathcolor) + bar.attrib["fill"] = str(self.style.mathcolor) return x, y class Box(Drawable): - ''' Box ''' - def __init__(self, width: float, height: float, lw: float, - cornerradius: Optional[float] = None, - style: Optional[MathStyle] = None, **kwargs): + """Box""" + + def __init__( + self, + width: float, + height: float, + lw: float, + cornerradius: Optional[float] = None, + style: Optional[MathStyle] = None, + **kwargs, + ): super().__init__() self.width = width self.height = height self.cornerradius = cornerradius self.lw = lw - self.phantom = kwargs.get('phantom', False) + self.phantom = kwargs.get("phantom", False) self.bbox = BBox(0, self.width, 0, self.height) self.style = style if style else MathStyle() def draw(self, x: float, y: float, svg: ET.Element) -> tuple[float, float]: - ''' Draw the node on the SVG + """Draw the node on the SVG - Args: - x: Horizontal position in SVG coordinates - y: Vertical position in SVG coordinates - svg: SVG drawing as XML - ''' + Args: + x: Horizontal position in SVG coordinates + y: Vertical position in SVG coordinates + svg: SVG drawing as XML + """ if not self.phantom: - bar = ET.SubElement(svg, 'rect') - bar.set('x', fmt(x)) - bar.set('y', fmt(y-self.height)) - bar.set('width', fmt(self.width)) - bar.set('height', fmt(self.height)) - bar.set('stroke-width', fmt(self.lw)) - bar.set('stroke', self.style.mathcolor) - bar.set('fill', self.style.mathbackground) + bar = ET.SubElement(svg, "rect") + bar.set("x", fmt(x)) + bar.set("y", fmt(y - self.height)) + bar.set("width", fmt(self.width)) + bar.set("height", fmt(self.height)) + if config.svg_classes: + bar.set("class", "box") + if self.lw >= 0: + bar.set("stroke-width", fmt(self.lw)) + if self.style.mathcolor: + bar.set("stroke", self.style.mathcolor) + if self.style.mathbackground: + bar.set("fill", self.style.mathbackground) if self.cornerradius: - bar.set('rx', fmt(self.cornerradius)) + bar.set("rx", fmt(self.cornerradius)) - return x+self.width, y + return x + self.width, y class Diagonal(Drawable): - ''' Diagonal Line - corners of Box ''' - def __init__(self, width: float, height: float, lw: float, - arrow: bool = False, - style: Optional[MathStyle] = None, **kwargs): + """Diagonal Line - corners of Box""" + + def __init__( + self, + width: float, + height: float, + lw: float, + arrow: bool = False, + style: Optional[MathStyle] = None, + **kwargs, + ): super().__init__() self.width = width self.height = height self.lw = lw self.arrow = arrow - self.phantom = kwargs.get('phantom', False) + self.phantom = kwargs.get("phantom", False) self.bbox = BBox(0, self.width, 0, self.height) self.style = style if style else MathStyle() @@ -224,67 +262,85 @@ def __init__(self, width: float, height: float, lw: float, if self.arrow: # Bbox needs to be a bit bigger to accomodate arrowhead theta = math.atan2(-self.height, self.width) - self.arroww = (10+self.lw*2) * math.cos(theta) - self.arrowh = (10+self.lw*2) * math.sin(theta) + self.arroww = (10 + self.lw * 2) * math.cos(theta) + self.arrowh = (10 + self.lw * 2) * math.sin(theta) def draw(self, x: float, y: float, svg: ET.Element) -> tuple[float, float]: - ''' Draw the node on the SVG + """Draw the node on the SVG - Args: - x: Horizontal position in SVG coordinates - y: Vertical position in SVG coordinates - svg: SVG drawing as XML - ''' + Args: + x: Horizontal position in SVG coordinates + y: Vertical position in SVG coordinates + svg: SVG drawing as XML + """ if not self.phantom: - bar = ET.SubElement(svg, 'path') + bar = ET.SubElement(svg, "path") if self.arrow: - arrowdef = ET.SubElement(svg, 'defs') - marker = ET.SubElement(arrowdef, 'marker') - marker.set('id', 'arrowhead') - marker.set('markerWidth', '10') - marker.set('markerHeight', '7') - marker.set('refX', '0') - marker.set('refY', '3.5') - marker.set('orient', 'auto') - poly = ET.SubElement(marker, 'polygon') - poly.set('points', '0 0 10 3.5 0 7') - - bar.set('d', f'M {fmt(x)} {fmt(y-self.height)} L {fmt(x+self.width)} {fmt(y)}') - bar.set('stroke-width', fmt(self.lw)) - bar.set('stroke', self.style.mathcolor) + arrowdef = ET.SubElement(svg, "defs") + marker = ET.SubElement(arrowdef, "marker") + marker.set("id", "arrowhead") + marker.set("markerWidth", "10") + marker.set("markerHeight", "7") + marker.set("refX", "0") + marker.set("refY", "3.5") + marker.set("orient", "auto") + poly = ET.SubElement(marker, "polygon") + poly.set("points", "0 0 10 3.5 0 7") + + bar.set( + "d", f"M {fmt(x)} {fmt(y-self.height)} L {fmt(x+self.width)} {fmt(y)}" + ) + if self.lw >= 0: + bar.set("stroke-width", fmt(self.lw)) + if self.style.mathcolor: + bar.set("stroke", self.style.mathcolor) if self.arrow: - bar.set('marker-end', 'url(#arrowhead)') + bar.set("marker-end", "url(#arrowhead)") + if config.svg_classes: + bar.set("class", "dline") - return x+self.width, y + return x + self.width, y class Ellipse(Drawable): - ''' Ellipse ''' - def __init__(self, width: float, height: float, lw: float, - style: Optional[MathStyle] = None, **kwargs): + """Ellipse""" + + def __init__( + self, + width: float, + height: float, + lw: float, + style: Optional[MathStyle] = None, + **kwargs, + ): super().__init__() self.width = width self.height = height self.lw = lw - self.phantom = kwargs.get('phantom', False) + self.phantom = kwargs.get("phantom", False) self.bbox = BBox(0, self.width, 0, self.height) self.style = style if style else MathStyle() def draw(self, x: float, y: float, svg: ET.Element) -> tuple[float, float]: - ''' Draw the node on the SVG + """Draw the node on the SVG - Args: - x: Horizontal position in SVG coordinates - y: Vertical position in SVG coordinates - svg: SVG drawing as XML - ''' + Args: + x: Horizontal position in SVG coordinates + y: Vertical position in SVG coordinates + svg: SVG drawing as XML + """ if not self.phantom: - bar = ET.SubElement(svg, 'ellipse') - bar.set('cx', fmt(x+self.width/2)) - bar.set('cy', fmt(y-self.height/2)) - bar.set('rx', fmt(self.width/2)) - bar.set('ry', fmt(self.height/2)) - bar.set('stroke-width', fmt(self.lw)) - bar.set('stroke', self.style.mathcolor) - bar.set('fill', self.style.mathbackground) - return x+self.width, y + bar = ET.SubElement(svg, "ellipse") + bar.set("cx", fmt(x + self.width / 2)) + bar.set("cy", fmt(y - self.height / 2)) + bar.set("rx", fmt(self.width / 2)) + bar.set("ry", fmt(self.height / 2)) + if config.svg_classes: + bar.set("class", "ellipse") + if self.lw >= 0: + bar.set("stroke-width", fmt(self.lw)) + if self.style.mathcolor: + bar.set("stroke", self.style.mathcolor) + if self.style.mathbackground: + bar.set("fill", self.style.mathbackground) + return x + self.width, y diff --git a/ziamath/nodes/mnode.py b/ziamath/nodes/mnode.py index 1deb884..f6c3a31 100644 --- a/ziamath/nodes/mnode.py +++ b/ziamath/nodes/mnode.py @@ -1,4 +1,5 @@ -''' Math node - parent class of all math nodes ''' +"""Math node - parent class of all math nodes""" + from __future__ import annotations from typing import Optional, MutableMapping, Type import logging @@ -14,20 +15,21 @@ from .. import operators from .nodetools import elementtext, infer_opform -_node_classes: dict[str, Type['Mnode']] = {} +_node_classes: dict[str, Type["Mnode"]] = {} class Mnode(Drawable): - ''' Math Drawing Node + """Math Drawing Node - Args: - element: XML element for the node - size: base font size in points - parent: Mnode of parent - ''' - mtag = 'mnode' + Args: + element: XML element for the node + size: base font size in points + parent: Mnode of parent + """ + + mtag = "mnode" - def __init__(self, element: ET.Element, parent: 'Mnode', **kwargs): + def __init__(self, element: ET.Element, parent: "Mnode", **kwargs): super().__init__() self.element = element self.font: MathFont = parent.font @@ -38,8 +40,11 @@ def __init__(self, element: ET.Element, parent: 'Mnode', **kwargs): self.nodes: list[Drawable] = [] self.nodexy: list[tuple[float, float]] = [] self.glyphsize = max( - self.size * (self.font.math.consts.scriptPercentScaleDown/100)**self.style.scriptlevel, - self.font.basesize*config.minsizefraction) + self.size + * (self.font.math.consts.scriptPercentScaleDown / 100) + ** self.style.scriptlevel, + self.font.basesize * config.minsizefraction, + ) if self.style.mathsize: self.glyphsize = self.size_px(self.style.mathsize) @@ -48,58 +53,58 @@ def __init__(self, element: ET.Element, parent: 'Mnode', **kwargs): self.bbox = BBox(0, 0, 0, 0) def __init_subclass__(cls, tag: str) -> None: - ''' Register this subclass so fromelement() can find it ''' + """Register this subclass so fromelement() can find it""" _node_classes[tag] = cls cls.mtag = tag @classmethod - def fromelement(cls, element: ET.Element, parent: 'Mnode', **kwargs) -> 'Mnode': - ''' Construct a new node from the element and its parent ''' - if element.tag in ['math', 'mtd', 'mtr', 'none']: - element.tag = 'mrow' - elif element.tag == 'ms': - element.tag = 'mtext' - elif element.tag == 'mi' and elementtext(element) in operators.names: + def fromelement(cls, element: ET.Element, parent: "Mnode", **kwargs) -> "Mnode": + """Construct a new node from the element and its parent""" + if element.tag in ["math", "mtd", "mtr", "none"]: + element.tag = "mrow" + elif element.tag == "ms": + element.tag = "mtext" + elif element.tag == "mi" and elementtext(element) in operators.names: # Workaround for some latex2mathml operators coming back as identifiers - element.tag = 'mo' + element.tag = "mo" - if element.tag == 'mo': + if element.tag == "mo": infer_opform(0, element, parent) node = _node_classes.get(element.tag, None) if node: return node(element, parent, **kwargs) - logging.warning('Undefined element %s', element) - return _node_classes['mrow'](element, parent, **kwargs) + logging.warning("Undefined element %s", element) + return _node_classes["mrow"](element, parent, **kwargs) def _setup(self, **kwargs) -> None: - ''' Calculate node position assuming this node is at 0, 0. Also set bbox. ''' + """Calculate node position assuming this node is at 0, 0. Also set bbox.""" self.bbox = BBox(0, 0, 0, 0) def units_to_points(self, value: float) -> float: - ''' Convert value in font units to points at this glyph size ''' + """Convert value in font units to points at this glyph size""" return value * self._glyph_pts_per_unit def font_units_to_points(self, value: float) -> float: - ''' Convert value in font units to points at the base font size ''' + """Convert value in font units to points at the base font size""" return value * self._font_pts_per_unit def points_to_units(self, value: float) -> float: - ''' Convert points back to font units ''' + """Convert points back to font units""" return value / self._glyph_pts_per_unit def increase_child_scriptlevel(self, element: ET.Element, n: int = 1) -> None: - ''' Increase the child element's script level one higher - than this element, if not overridden in child's attributes - ''' - element.attrib.setdefault('scriptlevel', str(self.style.scriptlevel+n)) + """Increase the child element's script level one higher + than this element, if not overridden in child's attributes + """ + element.attrib.setdefault("scriptlevel", str(self.style.scriptlevel + n)) def leftsibling(self) -> Optional[Drawable]: - ''' Left node sibling. The one that was just placed. ''' + """Left node sibling. The one that was just placed.""" try: node = self.parent.nodes[-1] - if node.mtag == 'mrow' and node.nodes: + if node.mtag == "mrow" and node.nodes: node = node.nodes[-1] except (IndexError, AttributeError): node = None @@ -107,50 +112,51 @@ def leftsibling(self) -> Optional[Drawable]: return node def firstglyph(self) -> Optional[SimpleGlyph]: - ''' Get the first glyph in this node ''' + """Get the first glyph in this node""" try: return self.nodes[0].firstglyph() except IndexError: return None def lastglyph(self) -> Optional[SimpleGlyph]: - ''' Get the last glyph in this node ''' + """Get the last glyph in this node""" try: return self.nodes[-1].lastglyph() except IndexError: return None def lastchar(self) -> Optional[str]: - ''' Get the last character in this node ''' + """Get the last character in this node""" try: return self.nodes[-1].lastchar() except IndexError: return None def size_px(self, size: str, fontsize: Optional[float] = None) -> float: - ''' Get size in points from the attribute string ''' + """Get size in points from the attribute string""" if fontsize is None: fontsize = self.glyphsize - numsize = {"veryverythinmathspace": f'{1/18}em', - "verythinmathspace": f'{2/18}em', - "thinmathspace": f'{3/18}em', - "mediummathspace": f'{4/18}em', - "thickmathspace": f'{5/18}em', - "verythickmathspace": f'{6/18}em', - "veryverythickmathspace": f'{7/18}em', - "negativeveryverythinmathspace": f'{-1/18}em', - "negativeverythinmathspace": f'{-2/18}em', - "negativethinmathspace": f'{-3/18}em', - "negativemediummathspace": f'{-4/18}em', - "negativethickmathspace": f'{-5/18}em', - "negativeverythickmathspace": f'{-6/18}em', - "negativeveryverythickmathspace": f'{-7/18}em', - }.get(size, size) + numsize = { + "veryverythinmathspace": f"{1/18}em", + "verythinmathspace": f"{2/18}em", + "thinmathspace": f"{3/18}em", + "mediummathspace": f"{4/18}em", + "thickmathspace": f"{5/18}em", + "verythickmathspace": f"{6/18}em", + "veryverythickmathspace": f"{7/18}em", + "negativeveryverythinmathspace": f"{-1/18}em", + "negativeverythinmathspace": f"{-2/18}em", + "negativethinmathspace": f"{-3/18}em", + "negativemediummathspace": f"{-4/18}em", + "negativethickmathspace": f"{-5/18}em", + "negativeverythickmathspace": f"{-6/18}em", + "negativeveryverythickmathspace": f"{-7/18}em", + }.get(size, size) try: # Plain number, or value in px - pxsize = float(numsize.rstrip('px')) + pxsize = float(numsize.rstrip("px")) except ValueError as exc: pass else: @@ -166,57 +172,67 @@ def size_px(self, size: str, fontsize: Optional[float] = None) -> float: # Conversion values from: # https://tex.stackexchange.com/questions/8260/what-are-the-various-units-ex-em-in-pt-bp-dd-pc-expressed-in-mm UNITS_TO_PT = { - 'pt': 1, - 'mm': 2.84526, - 'cm': 28.45274, - 'ex': 4.30554, - 'em': 10.00002, - 'bp': 1.00374, - 'dd': 1.07, - 'pc': 12, - 'in': 72.27, - 'mu': 0.5555, + "pt": 1, + "mm": 2.84526, + "cm": 28.45274, + "ex": 4.30554, + "em": 10.00002, + "bp": 1.00374, + "dd": 1.07, + "pc": 12, + "in": 72.27, + "mu": 0.5555, } # Convert units to points, then to pixels (= 1.333 px/pt) pxsize = value * UNITS_TO_PT.get(units, 0) * 1.333 - if units in ['em', 'ex', 'mu']: + if units in ["em", "ex", "mu"]: # These are fontsize dependent, table is based # on 10-point font - pxsize *= fontsize/10 + pxsize *= fontsize / 10 return pxsize def draw(self, x: float, y: float, svg: ET.Element) -> tuple[float, float]: - ''' Draw the node on the SVG + """Draw the node on the SVG + + Args: + x: Horizontal position in SVG coordinates + y: Vertical position in SVG coordinates + svg: SVG drawing as XML + """ + if config.svg_classes: + g = ET.SubElement(svg, "g") + g.set("class", self.element.tag) + svg = g - Args: - x: Horizontal position in SVG coordinates - y: Vertical position in SVG coordinates - svg: SVG drawing as XML - ''' if config.debug.bbox: - rect = ET.SubElement(svg, 'rect') - rect.set('x', fmt(x + self.bbox.xmin)) - rect.set('y', fmt(y - self.bbox.ymax)) - rect.set('width', fmt((self.bbox.xmax - self.bbox.xmin))) - rect.set('height', fmt((self.bbox.ymax - self.bbox.ymin))) - rect.set('fill', 'none') - rect.set('stroke', 'blue') - rect.set('stroke-width', '0.2') + rect = ET.SubElement(svg, "rect") + rect.set("x", fmt(x + self.bbox.xmin)) + rect.set("y", fmt(y - self.bbox.ymax)) + rect.set("width", fmt((self.bbox.xmax - self.bbox.xmin))) + rect.set("height", fmt((self.bbox.ymax - self.bbox.ymin))) + rect.set("fill", "none") + rect.set("stroke", "blue") + rect.set("stroke-width", "0.2") + if config.svg_classes: + rect.set("class", "bbox") if config.debug.baseline: - base = ET.SubElement(svg, 'path') - base.set('d', f'M {x} 0 L {x+self.bbox.xmax} 0') - base.set('stroke', 'red') - - if self.style.mathbackground not in ['none', None]: - rect = ET.SubElement(svg, 'rect') - rect.set('x', fmt(x + self.bbox.xmin)) - rect.set('y', fmt(y - self.bbox.ymax)) - rect.set('width', fmt((self.bbox.xmax - self.bbox.xmin))) - rect.set('height', fmt((self.bbox.ymax - self.bbox.ymin))) - rect.set('fill', str(self.style.mathbackground)) - - nodex = nodey = 0. + base = ET.SubElement(svg, "path") + base.set("d", f"M {x} 0 L {x+self.bbox.xmax} 0") + base.set("stroke", "red") + if config.svg_classes: + rect.set("class", "baseline") + if self.style.mathbackground not in ["none", None]: + rect = ET.SubElement(svg, "rect") + rect.set("x", fmt(x + self.bbox.xmin)) + rect.set("y", fmt(y - self.bbox.ymax)) + rect.set("width", fmt((self.bbox.xmax - self.bbox.xmin))) + rect.set("height", fmt((self.bbox.ymax - self.bbox.ymin))) + if self.style.mathbackground: + rect.set("fill", str(self.style.mathbackground)) + if config.svg_classes: + rect.set("class", "background") + nodex = nodey = 0.0 for (nodex, nodey), node in zip(self.nodexy, self.nodes): - node.draw(x+nodex, y+nodey, svg) - return x+nodex, y+nodey + node.draw(x + nodex, y + nodey, svg) + return x + nodex, y + nodey diff --git a/ziamath/styles.py b/ziamath/styles.py index 1989012..7ea4e2f 100644 --- a/ziamath/styles.py +++ b/ziamath/styles.py @@ -1,7 +1,8 @@ -''' Apply italic, bold, and other font styles by shifting the unstyled ASCII - characters [A-Z, a-z, and 0-9] to their higher unicode alternatives. Note - this does not check whether the new character glyph exists in the font. -''' +"""Apply italic, bold, and other font styles by shifting the unstyled ASCII +characters [A-Z, a-z, and 0-9] to their higher unicode alternatives. Note +this does not check whether the new character glyph exists in the font. +""" + from __future__ import annotations from typing import Optional, Any, MutableMapping from collections import ChainMap, namedtuple @@ -11,14 +12,15 @@ from .config import config -VARIANTS = ['serif', 'sans', 'script', 'double', 'mono', 'fraktur'] -Styletype = namedtuple('Styletype', 'bold italic') +VARIANTS = ["serif", "sans", "script", "double", "mono", "fraktur"] +Styletype = namedtuple("Styletype", "bold italic") @dataclass class MathVariant: - ''' Math font variant, such as serif, sans, script, italic, etc. ''' - style: str = 'serif' + """Math font variant, such as serif, sans, script, italic, etc.""" + + style: str = "serif" italic: bool = False bold: bool = False normal: bool = False @@ -26,22 +28,23 @@ class MathVariant: @dataclass class MathStyle: - ''' Math Style parameters ''' + """Math Style parameters""" + mathvariant: MathVariant = field(default_factory=MathVariant) displaystyle: bool = True - mathcolor: str = 'black' - mathbackground: str = 'none' - mathsize: str = '' + mathcolor: str = "" + mathbackground: str = "none" + mathsize: str = "" scriptlevel: int = 0 def parse_variant(variant: str, parent_variant: MathVariant) -> MathVariant: - ''' Extract mathvariant from MathML attribute and parent's variant ''' - bold = True if 'bold' in variant else parent_variant.bold - italic = True if 'italic' in variant else parent_variant.italic - normal = True if 'normal' in variant else parent_variant.normal + """Extract mathvariant from MathML attribute and parent's variant""" + bold = True if "bold" in variant else parent_variant.bold + italic = True if "italic" in variant else parent_variant.italic + normal = True if "normal" in variant else parent_variant.normal - variant = variant.replace('bold', '').replace('italic', '').strip() + variant = variant.replace("bold", "").replace("italic", "").strip() if variant in VARIANTS: style = variant else: @@ -51,17 +54,19 @@ def parse_variant(variant: str, parent_variant: MathVariant) -> MathVariant: def parse_displaystyle(params: MutableMapping[str, Any]) -> bool: - ''' Extract displaystyle mode from MathML attributes ''' + """Extract displaystyle mode from MathML attributes""" dstyle = True - if 'displaystyle' in params: - dstyle = params.get('displaystyle') in ['true', True] - elif 'display' in params: - dstyle = params.get('display', 'block') != 'inline' + if "displaystyle" in params: + dstyle = params.get("displaystyle") in ["true", True] + elif "display" in params: + dstyle = params.get("display", "block") != "inline" return dstyle -def parse_style(element: ET.Element, parent_style: Optional[MathStyle] = None) -> MathStyle: - ''' Read element style attributes into MathStyle ''' +def parse_style( + element: ET.Element, parent_style: Optional[MathStyle] = None +) -> MathStyle: + """Read element style attributes into MathStyle""" params: MutableMapping[str, Any] if parent_style: params = ChainMap(element.attrib, asdict(parent_style)) @@ -71,173 +76,200 @@ def parse_style(element: ET.Element, parent_style: Optional[MathStyle] = None) - parent_variant = MathVariant() args: dict[str, Any] = {} - args['mathcolor'] = params.get('mathcolor', config.math.color) - args['mathbackground'] = params.get('mathbackground', config.math.background) - args['mathsize'] = params.get('mathsize', '') - args['scriptlevel'] = int(params.get('scriptlevel', 0)) - args['mathvariant'] = parse_variant(element.attrib.get('mathvariant', config.math.variant), parent_variant) - args['displaystyle'] = parse_displaystyle(params) - - css = params.get('style', '') + args["mathcolor"] = params.get("mathcolor", config.math.color) + args["mathbackground"] = params.get("mathbackground", config.math.background) + args["mathsize"] = params.get("mathsize", "") + args["scriptlevel"] = int(params.get("scriptlevel", 0)) + args["mathvariant"] = parse_variant( + element.attrib.get("mathvariant", config.math.variant), parent_variant + ) + args["displaystyle"] = parse_displaystyle(params) + + css = params.get("style", "") if css: - cssparams = css.split(';') + cssparams = css.split(";") for cssparam in cssparams: if not cssparam: continue # blank lines - key, val = cssparam.split(':') + key, val = cssparam.split(":") key = key.strip() val = val.strip() - if key.lower() == 'background': - args['mathbackground'] = val - elif key.lower() == 'color': - args['mathcolor'] = val + if key.lower() == "background": + args["mathbackground"] = val + elif key.lower() == "color": + args["mathcolor"] = val return MathStyle(**args) LATIN_CAP_RANGE = (0x41, 0x5A) -LATIN_CAPS = \ - {'serif': {Styletype(bold=False, italic=False): 0x0000, - Styletype(bold=True, italic=False): 0x1D400, - Styletype(bold=False, italic=True): 0x1D434, - Styletype(bold=True, italic=True): 0x1D468 - }, - 'sans': {Styletype(bold=False, italic=False): 0x1D5A0, - Styletype(bold=True, italic=False): 0x1D5D4, - Styletype(bold=False, italic=True): 0x1D608, - Styletype(bold=True, italic=True): 0x1D63C - }, - 'script': {Styletype(bold=False, italic=False): 0x1D49C, - Styletype(bold=True, italic=False): 0x1D4D0, - Styletype(bold=True, italic=True): 0x1D4D0 # No separate italic - }, - 'fraktur': {Styletype(bold=False, italic=False): 0x1D504, - Styletype(bold=True, italic=False): 0x1D56C, - Styletype(bold=True, italic=True): 0x1D56C # No separate italic - }, - 'mono': {Styletype(bold=False, italic=False): 0x1D670, - }, - 'double': {Styletype(bold=False, italic=False): 0x1D538, - }, - } - -LATIN_SMALL_RANGE = (0x61, 0x7a) -LATIN_SMALL = \ - {'serif': {Styletype(bold=False, italic=False): 0x0000, - Styletype(bold=True, italic=False): 0x1D41A, - Styletype(bold=False, italic=True): 0x1D44E, - Styletype(bold=True, italic=True): 0x1D482 - }, - 'sans': {Styletype(bold=False, italic=False): 0x1D5BA, - Styletype(bold=True, italic=False): 0x1D5EE, - Styletype(bold=False, italic=True): 0x1D622, - Styletype(bold=True, italic=True): 0x1D656 - }, - 'script': {Styletype(bold=False, italic=False): 0x1D4B6, - Styletype(bold=True, italic=False): 0x1D4EA, - Styletype(bold=True, italic=True): 0x1D4EA # No separate italic - }, - 'fraktur': {Styletype(bold=False, italic=False): 0x1D51E, - Styletype(bold=True, italic=False): 0x1D586, - Styletype(bold=True, italic=True): 0x1D586 # No separate italic - }, - 'mono': {Styletype(bold=False, italic=False): 0x1D68A, - }, - 'double': {Styletype(bold=False, italic=False): 0x1D552, - }, - } +LATIN_CAPS = { + "serif": { + Styletype(bold=False, italic=False): 0x0000, + Styletype(bold=True, italic=False): 0x1D400, + Styletype(bold=False, italic=True): 0x1D434, + Styletype(bold=True, italic=True): 0x1D468, + }, + "sans": { + Styletype(bold=False, italic=False): 0x1D5A0, + Styletype(bold=True, italic=False): 0x1D5D4, + Styletype(bold=False, italic=True): 0x1D608, + Styletype(bold=True, italic=True): 0x1D63C, + }, + "script": { + Styletype(bold=False, italic=False): 0x1D49C, + Styletype(bold=True, italic=False): 0x1D4D0, + Styletype(bold=True, italic=True): 0x1D4D0, # No separate italic + }, + "fraktur": { + Styletype(bold=False, italic=False): 0x1D504, + Styletype(bold=True, italic=False): 0x1D56C, + Styletype(bold=True, italic=True): 0x1D56C, # No separate italic + }, + "mono": { + Styletype(bold=False, italic=False): 0x1D670, + }, + "double": { + Styletype(bold=False, italic=False): 0x1D538, + }, +} + +LATIN_SMALL_RANGE = (0x61, 0x7A) +LATIN_SMALL = { + "serif": { + Styletype(bold=False, italic=False): 0x0000, + Styletype(bold=True, italic=False): 0x1D41A, + Styletype(bold=False, italic=True): 0x1D44E, + Styletype(bold=True, italic=True): 0x1D482, + }, + "sans": { + Styletype(bold=False, italic=False): 0x1D5BA, + Styletype(bold=True, italic=False): 0x1D5EE, + Styletype(bold=False, italic=True): 0x1D622, + Styletype(bold=True, italic=True): 0x1D656, + }, + "script": { + Styletype(bold=False, italic=False): 0x1D4B6, + Styletype(bold=True, italic=False): 0x1D4EA, + Styletype(bold=True, italic=True): 0x1D4EA, # No separate italic + }, + "fraktur": { + Styletype(bold=False, italic=False): 0x1D51E, + Styletype(bold=True, italic=False): 0x1D586, + Styletype(bold=True, italic=True): 0x1D586, # No separate italic + }, + "mono": { + Styletype(bold=False, italic=False): 0x1D68A, + }, + "double": { + Styletype(bold=False, italic=False): 0x1D552, + }, +} GREEK_CAP_RANGE = (0x0391, 0x3AA) -GREEK_CAPS = \ - {'serif': {Styletype(bold=False, italic=False): 0x0000, - Styletype(bold=True, italic=False): 0x1D6A8, - Styletype(bold=False, italic=True): 0x1D6E2, - Styletype(bold=True, italic=True): 0x1D71C - }, - 'sans': {Styletype(bold=False, italic=False): 0x0000, - Styletype(bold=True, italic=False): 0x1D756, - Styletype(bold=True, italic=True): 0x1D790 - }, - } - -GREEK_LOWER_RANGE = (0x3b1, 0x3d0) -GREEK_LOWER = \ - {'serif': {Styletype(bold=False, italic=False): 0x0000, - Styletype(bold=True, italic=False): 0x1D6C2, - Styletype(bold=False, italic=True): 0x1D6FC, - Styletype(bold=True, italic=True): 0x1D736 - }, - 'sans': {Styletype(bold=False, italic=False): 0x0000, - Styletype(bold=True, italic=False): 0x1D770, - Styletype(bold=True, italic=True): 0x1D7AA - }, - } +GREEK_CAPS = { + "serif": { + Styletype(bold=False, italic=False): 0x0000, + Styletype(bold=True, italic=False): 0x1D6A8, + Styletype(bold=False, italic=True): 0x1D6E2, + Styletype(bold=True, italic=True): 0x1D71C, + }, + "sans": { + Styletype(bold=False, italic=False): 0x0000, + Styletype(bold=True, italic=False): 0x1D756, + Styletype(bold=True, italic=True): 0x1D790, + }, +} + +GREEK_LOWER_RANGE = (0x3B1, 0x3D0) +GREEK_LOWER = { + "serif": { + Styletype(bold=False, italic=False): 0x0000, + Styletype(bold=True, italic=False): 0x1D6C2, + Styletype(bold=False, italic=True): 0x1D6FC, + Styletype(bold=True, italic=True): 0x1D736, + }, + "sans": { + Styletype(bold=False, italic=False): 0x0000, + Styletype(bold=True, italic=False): 0x1D770, + Styletype(bold=True, italic=True): 0x1D7AA, + }, +} DIGIT_RANGE = (0x30, 0x39) -DIGITS = \ - {'serif': {Styletype(bold=False, italic=False): 0x0000, - Styletype(bold=True, italic=False): 0x1D7CE, - }, - 'double': {Styletype(bold=False, italic=False): 0x1D7D8, }, - 'mono': {Styletype(bold=False, italic=False): 0x1D7F6, }, - 'sans': {Styletype(bold=False, italic=False): 0x1D7E2, - Styletype(bold=True, italic=False): 0x1D7EC, - Styletype(bold=True, italic=True): 0x1D7EC, - }, - } - - -subtables = ((LATIN_CAP_RANGE, LATIN_CAPS), - (LATIN_SMALL_RANGE, LATIN_SMALL), - (GREEK_CAP_RANGE, GREEK_CAPS), - (GREEK_LOWER_RANGE, GREEK_LOWER), - (DIGIT_RANGE, DIGITS)) +DIGITS = { + "serif": { + Styletype(bold=False, italic=False): 0x0000, + Styletype(bold=True, italic=False): 0x1D7CE, + }, + "double": { + Styletype(bold=False, italic=False): 0x1D7D8, + }, + "mono": { + Styletype(bold=False, italic=False): 0x1D7F6, + }, + "sans": { + Styletype(bold=False, italic=False): 0x1D7E2, + Styletype(bold=True, italic=False): 0x1D7EC, + Styletype(bold=True, italic=True): 0x1D7EC, + }, +} + + +subtables = ( + (LATIN_CAP_RANGE, LATIN_CAPS), + (LATIN_SMALL_RANGE, LATIN_SMALL), + (GREEK_CAP_RANGE, GREEK_CAPS), + (GREEK_LOWER_RANGE, GREEK_LOWER), + (DIGIT_RANGE, DIGITS), +) # These are the yellow characters in wikipedia's table OFFSET_EXCEPTIONS = { - 'ϴ': 0x0391+0x11, - '∇': 0x0391+0x19, - '∂': 0x03B1+0x19, - 'ϵ': 0x03B1+0x1A, - 'ϑ': 0x03B1+0x1B, - 'ϰ': 0x03B1+0x1C, - 'ϕ': 0x03B1+0x1D, - 'ϱ': 0x03B1+0x1E, - 'ϖ': 0x03B1+0x1F} + "ϴ": 0x0391 + 0x11, + "∇": 0x0391 + 0x19, + "∂": 0x03B1 + 0x19, + "ϵ": 0x03B1 + 0x1A, + "ϑ": 0x03B1 + 0x1B, + "ϰ": 0x03B1 + 0x1C, + "ϕ": 0x03B1 + 0x1D, + "ϱ": 0x03B1 + 0x1E, + "ϖ": 0x03B1 + 0x1F, +} EXCEPTIONS = { - 0x1D49C+0x01: 'ℬ', # latin cap scripts - 0x1D49C+0x04: 'ℰ', - 0x1D49C+0x05: 'ℱ', - 0x1D49C+0x07: 'ℋ', - 0x1D49C+0x08: 'ℐ', - 0x1D49C+0x0B: 'ℒ', - 0x1D49C+0x0C: 'ℳ', - 0x1D49C+0x11: 'ℛ', - 0x1D504+0x02: 'ℭ', # latin cap frakturs - 0x1D504+0x07: 'ℌ', - 0x1D504+0x08: 'ℑ', - 0x1D504+0x11: 'ℜ', - 0x1D504+0x19: 'ℨ', - 0x1D538+0x02: 'ℂ', # latin cap doubles - 0x1D538+0x07: 'ℍ', - 0x1D538+0x0D: 'ℕ', - 0x1D538+0x0F: 'ℙ', - 0x1D538+0x10: 'ℚ', - 0x1D538+0x11: 'ℝ', - 0x1D538+0x19: 'ℤ', - 0x1D44E+0x07: 'ℎ', # latin small italic - 0x1D4B6+0x04: 'ℯ', # latin small script - 0x1D4B6+0x06: 'ℊ', - 0x1D4B6+0x0E: 'ℴ', - } + 0x1D49C + 0x01: "ℬ", # latin cap scripts + 0x1D49C + 0x04: "ℰ", + 0x1D49C + 0x05: "ℱ", + 0x1D49C + 0x07: "ℋ", + 0x1D49C + 0x08: "ℐ", + 0x1D49C + 0x0B: "ℒ", + 0x1D49C + 0x0C: "ℳ", + 0x1D49C + 0x11: "ℛ", + 0x1D504 + 0x02: "ℭ", # latin cap frakturs + 0x1D504 + 0x07: "ℌ", + 0x1D504 + 0x08: "ℑ", + 0x1D504 + 0x11: "ℜ", + 0x1D504 + 0x19: "ℨ", + 0x1D538 + 0x02: "ℂ", # latin cap doubles + 0x1D538 + 0x07: "ℍ", + 0x1D538 + 0x0D: "ℕ", + 0x1D538 + 0x0F: "ℙ", + 0x1D538 + 0x10: "ℚ", + 0x1D538 + 0x11: "ℝ", + 0x1D538 + 0x19: "ℤ", + 0x1D44E + 0x07: "ℎ", # latin small italic + 0x1D4B6 + 0x04: "ℯ", # latin small script + 0x1D4B6 + 0x06: "ℊ", + 0x1D4B6 + 0x0E: "ℴ", +} def auto_italic(char: str) -> bool: - ''' Determine whether the character should be automatically - converted to italic - ''' + """Determine whether the character should be automatically + converted to italic + """ ordchr = ord(char) for ordrange in (GREEK_LOWER_RANGE, LATIN_SMALL_RANGE, LATIN_CAP_RANGE): if ordrange[0] <= ordchr <= ordrange[1]: @@ -246,9 +278,9 @@ def auto_italic(char: str) -> bool: def styledchr(char, variant: MathVariant): - ''' Convert character to its styled (bold, italic, script, etc.) variant. - See tables at: https://en.wikipedia.org/wiki/Mathematical_Alphanumeric_Symbols - ''' + """Convert character to its styled (bold, italic, script, etc.) variant. + See tables at: https://en.wikipedia.org/wiki/Mathematical_Alphanumeric_Symbols + """ script = variant.style style = Styletype(variant.bold, variant.italic) styledchr = char # Default is to return char unchanged @@ -257,7 +289,7 @@ def styledchr(char, variant: MathVariant): for ordrange, table in subtables: if ordrange[0] <= charord <= ordrange[1]: ordoffset = charord - ordrange[0] - scripttable = table.get(script, table.get('serif')) + scripttable = table.get(script, table.get("serif")) offset = scripttable.get(style, scripttable.get(Styletype(False, False))) # type: ignore if offset: styledchr = chr(ordoffset + offset) @@ -268,5 +300,5 @@ def styledchr(char, variant: MathVariant): def styledstr(st: str, variant: MathVariant) -> str: - ''' Apply unicode styling conversion to a string ''' - return ''.join([styledchr(s, variant) for s in st]) + """Apply unicode styling conversion to a string""" + return "".join([styledchr(s, variant) for s in st]) diff --git a/ziamath/zmath.py b/ziamath/zmath.py index 5233731..196671d 100644 --- a/ziamath/zmath.py +++ b/ziamath/zmath.py @@ -1,4 +1,4 @@ -''' Main math rendering class ''' +"""Main math rendering class""" from __future__ import annotations from typing import Union, Literal, Tuple, Optional, Dict @@ -21,47 +21,49 @@ from .tex import tex2mml -Halign = Literal['left', 'center', 'right'] -Valign = Literal['top', 'center', 'base', 'axis', 'bottom'] +Halign = Literal["left", "center", "right"] +Valign = Literal["top", "center", "base", "axis", "bottom"] def denamespace(element: ET.Element) -> ET.Element: - ''' Recursively remove namespace {...} from beginning of xml - element names, so they can be searched easily. - ''' - if element.tag.startswith('{'): - element.tag = element.tag.split('}')[1] + """Recursively remove namespace {...} from beginning of xml + element names, so they can be searched easily. + """ + if element.tag.startswith("{"): + element.tag = element.tag.split("}")[1] for elm in element: denamespace(elm) return element def apply_mstyle(element: ET.Element) -> ET.Element: - ''' Take attributes defined in elements and add them - to all the child elements, removing the original - ''' + """Take attributes defined in elements and add them + to all the child elements, removing the original + """ + def flatten_attrib(element: ET.Element) -> None: for child in element: - if element.tag == 'mstyle': + if element.tag == "mstyle": child.attrib = dict(ChainMap(child.attrib, element.attrib)) flatten_attrib(child) flatten_attrib(element) - elmstr = ET.tostring(element).decode('utf-8') - elmstr = re.sub(r'', '', elmstr) - elmstr = re.sub(r'', '', elmstr) + elmstr = ET.tostring(element).decode("utf-8") + elmstr = re.sub(r"", "", elmstr) + elmstr = re.sub(r"", "", elmstr) return ET.fromstring(elmstr) class EqNumbering: - ''' Manage automatic equation numbers ''' + """Manage automatic equation numbers""" + count: int = 1 enable: bool = True @classmethod def number(cls) -> Optional[int]: - ''' Get next number in the sequence ''' + """Get next number in the sequence""" if EqNumbering.enable: number = EqNumbering.count EqNumbering.count += 1 @@ -71,19 +73,19 @@ def number(cls) -> Optional[int]: @classmethod @contextmanager def pause(cls): - ''' Context manager to pause equation numbering ''' + """Context manager to pause equation numbering""" EqNumbering.enable = False yield EqNumbering.enable = True @classmethod def reset(cls, number: int = 1) -> None: - ''' Reset the current number ''' + """Reset the current number""" EqNumbering.count = number @classmethod - def text(cls, number = None) -> Optional[str]: - ''' Get text to use as equation label ''' + def text(cls, number=None) -> Optional[str]: + """Get text to use as equation label""" if EqNumbering.enable: if number is None: number = EqNumbering.number() @@ -92,26 +94,28 @@ def text(cls, number = None) -> Optional[str]: def reset_numbering(number: int = 1): - ''' Reset equation numbering ''' + """Reset equation numbering""" EqNumbering.reset(number) - class Math: - ''' MathML Element Renderer - - Args: - mathml: MathML expression, in string or XML Element - size: Base font size, pixels - font: Filename of font file. Must contain MATH typesetting table. - title: Text for title alt-text tag in the SVG - ''' - def __init__(self, - mathml: Union[str, ET.Element], - size: Optional[float] = None, - font: Optional[str] = None, - title: Optional[str] = None, - number: Optional[str] = None): + """MathML Element Renderer + + Args: + mathml: MathML expression, in string or XML Element + size: Base font size, pixels + font: Filename of font file. Must contain MATH typesetting table. + title: Text for title alt-text tag in the SVG + """ + + def __init__( + self, + mathml: Union[str, ET.Element], + size: Optional[float] = None, + font: Optional[str] = None, + title: Optional[str] = None, + number: Optional[str] = None, + ): self.size = size if size else config.math.fontsize font = font if font else config.math.mathfont self.title = title @@ -123,7 +127,7 @@ def __init__(self, self.font: MathFont if font is None: - self.font = loadedfonts['default'] + self.font = loadedfonts["default"] elif font in loadedfonts: self.font = loadedfonts[font] else: @@ -144,7 +148,9 @@ def register_altfont(path): if config.math.italic_font: self.font.alt_fonts.italic = register_altfont(config.math.italic_font) if config.math.bolditalic_font: - self.font.alt_fonts.bolditalic = register_altfont(config.math.bolditalic_font) + self.font.alt_fonts.bolditalic = register_altfont( + config.math.bolditalic_font + ) if isinstance(mathml, str): mathml = unescape(mathml) @@ -155,88 +161,118 @@ def register_altfont(path): self.mathml = mathml self.style = parse_style(mathml) self.element = mathml - self.mtag = 'math' + self.mtag = "math" self.node = Mnode.fromelement(mathml, parent=self) # type: ignore @classmethod - def fromlatex(cls, latex: str, size: Optional[float] = None, mathstyle: Optional[str] = None, - font: Optional[str] = None, color: Optional[str] = None, inline: bool = False): - ''' Create Math Renderer from a single LaTeX expression. Requires - latex2mathml Python package. - - Args: - latex: Latex string - size: Base font size - mathstyle: Style parameter for math, equivalent to "mathvariant" MathML attribute - font: Font file name - color: Color parameter, equivalent to "mathcolor" attribute - inline: Use inline math mode (default is block mode) - ''' + def fromlatex( + cls, + latex: str, + size: Optional[float] = None, + mathstyle: Optional[str] = None, + font: Optional[str] = None, + color: Optional[str] = None, + inline: bool = False, + ): + """Create Math Renderer from a single LaTeX expression. Requires + latex2mathml Python package. + + Args: + latex: Latex string + size: Base font size + mathstyle: Style parameter for math, equivalent to "mathvariant" MathML attribute + font: Font file name + color: Color parameter, equivalent to "mathcolor" attribute + inline: Use inline math mode (default is block mode) + """ mathml: Union[str, ET.Element] mathml = tex2mml(latex, inline=inline) if mathstyle: mathml = ET.fromstring(mathml) - mathml.attrib['mathvariant'] = mathstyle - mathml = ET.tostring(mathml, encoding='unicode') + mathml.attrib["mathvariant"] = mathstyle + mathml = ET.tostring(mathml, encoding="unicode") if color: mathml = ET.fromstring(mathml) - mathml.attrib['mathcolor'] = color + mathml.attrib["mathcolor"] = color return cls(mathml, size, font) @classmethod - def fromlatextext(cls, latex: str, size: float = 24, mathstyle: Optional[str] = None, - textstyle: Optional[str] = None, font: Optional[str] = None, - color: Optional[str] = None): - ''' Create Math Renderer from a sentence containing zero or more LaTeX - expressions delimited by $..$, resulting in single MathML element. - Requires latex2mathml Python package. - - Args: - latex: string - size: Base font size - mathstyle: Style parameter for math, equivalent to "mathvariant" MathML attribute - textstyle: Style parameter for text, equivalent to "mathvariant" MathML attribute - font: Font file name - color: Color parameter, equivalent to "mathcolor" attribute - ''' - warnings.warn(r'fromlatextext is deprecated. Use ziamath.Text or \text{} command.', DeprecationWarning, stacklevel=2) + def fromlatextext( + cls, + latex: str, + size: float = 24, + mathstyle: Optional[str] = None, + textstyle: Optional[str] = None, + font: Optional[str] = None, + color: Optional[str] = None, + ): + """Create Math Renderer from a sentence containing zero or more LaTeX + expressions delimited by $..$, resulting in single MathML element. + Requires latex2mathml Python package. + + Args: + latex: string + size: Base font size + mathstyle: Style parameter for math, equivalent to "mathvariant" MathML attribute + textstyle: Style parameter for text, equivalent to "mathvariant" MathML attribute + font: Font file name + color: Color parameter, equivalent to "mathcolor" attribute + """ + warnings.warn( + r"fromlatextext is deprecated. Use ziamath.Text or \text{} command.", + DeprecationWarning, + stacklevel=2, + ) # Extract each $..$, convert to MathML, but the raw text in , and join # into a single - parts = re.split(r'(\$+.*?\$+)', latex) + parts = re.split(r"(\$+.*?\$+)", latex) texts = parts[::2] - maths = [tex2mml(p.replace('$', ''), inline=not p.startswith('$$')) for p in parts[1::2]] - mathels = [ET.fromstring(m) for m in maths] # Convert to xml, but drop opening - - mml = ET.Element('math') + maths = [ + tex2mml(p.replace("$", ""), inline=not p.startswith("$$")) + for p in parts[1::2] + ] + mathels = [ + ET.fromstring(m) for m in maths + ] # Convert to xml, but drop opening + + mml = ET.Element("math") for text, mathel in zip_longest(texts, mathels): if text: - mtext = ET.SubElement(mml, 'mtext') + mtext = ET.SubElement(mml, "mtext") if textstyle: - mtext.attrib['mathvariant'] = textstyle + mtext.attrib["mathvariant"] = textstyle mtext.text = text if mathel is not None: child = mathel[0] if mathstyle: - child.attrib['mathvariant'] = mathstyle - if (dstyle := mathel.attrib.get('display')): - child.attrib['displaystyle'] = {'inline': 'false', - 'block': 'true'}.get(dstyle, 'true') + child.attrib["mathvariant"] = mathstyle + if dstyle := mathel.attrib.get("display"): + child.attrib["displaystyle"] = { + "inline": "false", + "block": "true", + }.get(dstyle, "true") mml.append(child) if color: - mml.attrib['mathcolor'] = color + mml.attrib["mathcolor"] = color return cls(mml, size, font) def svgxml(self) -> ET.Element: - ''' Get standalone SVG of expression as XML Element Tree ''' - svg = ET.Element('svg') - svg.attrib['xmlns'] = 'http://www.w3.org/2000/svg' + """Get standalone SVG of expression as XML Element Tree""" + svg = ET.Element("svg") + svg.attrib["xmlns"] = "http://www.w3.org/2000/svg" if not config.svg2: - svg.attrib['xmlns:xlink'] = 'http://www.w3.org/1999/xlink' + svg.attrib["xmlns:xlink"] = "http://www.w3.org/1999/xlink" if isinstance(self.title, str): - title = ET.SubElement(svg, 'title') + title = ET.SubElement(svg, "title") title.text = self.title + if config.svg_style: + style = ET.SubElement(svg, "style") + style.text = config.svg_style + if config.svg_defs: + defs = ET.SubElement(svg, "defs") + defs.append(ET.fromstring(config.svg_defs)) bbox = self.node.bbox width = bbox.xmax - bbox.xmin + 2 # Add a 1-px border @@ -249,160 +285,193 @@ def svgxml(self) -> ET.Element: with EqNumbering.pause(): eqnode = Latex(self.eqnumber, size=self.size) - eqnode.drawon(svg, width, 0, halign='right') - y0 = min(-bbox.ymax-1, -eqnode.node.bbox.ymax-1) + eqnode.drawon(svg, width, 0, halign="right") + y0 = min(-bbox.ymax - 1, -eqnode.node.bbox.ymax - 1) y1 = max(-bbox.ymin, -eqnode.node.bbox.ymin) - height = max(height, y1-y0) - viewbox = f'0 {fmt(y0)} {fmt(width)} {fmt(height)}' + height = max(height, y1 - y0) + viewbox = f"0 {fmt(y0)} {fmt(width)} {fmt(height)}" else: x = 1 - viewbox = f'{fmt(bbox.xmin-1)} {fmt(-bbox.ymax-1)} {fmt(width)} {fmt(height)}' + viewbox = ( + f"{fmt(bbox.xmin-1)} {fmt(-bbox.ymax-1)} {fmt(width)} {fmt(height)}" + ) self.node.draw(x, 0, svg) - svg.attrib['width'] = fmt(width) - svg.attrib['height'] = fmt(height) - svg.attrib['viewBox'] = viewbox + svg.attrib["width"] = fmt(width) + svg.attrib["height"] = fmt(height) + svg.attrib["viewBox"] = viewbox if self.eqnumber is not None and config.debug.bbox: - rect = ET.SubElement(svg, 'rect') - rect.attrib['x'] = '0' - rect.attrib['y'] = fmt(y0) - rect.attrib['width'] = fmt(width) - rect.attrib['height'] = fmt(height) - rect.attrib['fill'] = 'yellow' - rect.attrib['opacity'] = '.2' - rect.attrib['stroke'] = 'red' + rect = ET.SubElement(svg, "rect") + rect.attrib["x"] = "0" + rect.attrib["y"] = fmt(y0) + rect.attrib["width"] = fmt(width) + rect.attrib["height"] = fmt(height) + rect.attrib["fill"] = "yellow" + rect.attrib["opacity"] = ".2" + rect.attrib["stroke"] = "red" return svg - def drawon(self, svg: ET.Element, x: float = 0, y: float = 0, - halign: Halign = 'left', valign: Valign = 'base') -> ET.Element: - ''' Draw the math expression on an existing SVG - - Args: - x: Horizontal position in SVG coordinates - y: Vertical position in SVG coordinates - svg: The image (top-level svg XML object) to draw on - halign: Horizontal alignment - valign: Vertical alignment - - Note: Horizontal alignment can be the typical 'left', 'center', or 'right'. - Vertical alignment can be 'top', 'bottom', or 'center' to align with the - expression's bounding box, or 'base' to align with the bottom - of the first text element, or 'axis', aligning with the height of a minus - sign above the baseline. - ''' + def drawon( + self, + svg: ET.Element, + x: float = 0, + y: float = 0, + halign: Halign = "left", + valign: Valign = "base", + ) -> ET.Element: + """Draw the math expression on an existing SVG + + Args: + x: Horizontal position in SVG coordinates + y: Vertical position in SVG coordinates + svg: The image (top-level svg XML object) to draw on + halign: Horizontal alignment + valign: Vertical alignment + + Note: Horizontal alignment can be the typical 'left', 'center', or 'right'. + Vertical alignment can be 'top', 'bottom', or 'center' to align with the + expression's bounding box, or 'base' to align with the bottom + of the first text element, or 'axis', aligning with the height of a minus + sign above the baseline. + """ width, height = self.getsize() - yshift = {'top': self.node.bbox.ymax, - 'center': height/2 + self.node.bbox.ymin, - 'axis': self.node.units_to_points(self.font.math.consts.axisHeight), - 'bottom': self.node.bbox.ymin}.get(valign, 0) - xshift = {'center': -width/2, - 'right': -width}.get(halign, 0) - - svgelm = ET.SubElement(svg, 'g') # Put it in a group - self.node.draw(x+xshift, y+yshift, svgelm) + yshift = { + "top": self.node.bbox.ymax, + "center": height / 2 + self.node.bbox.ymin, + "axis": self.node.units_to_points(self.font.math.consts.axisHeight), + "bottom": self.node.bbox.ymin, + }.get(valign, 0) + xshift = {"center": -width / 2, "right": -width}.get(halign, 0) + + svgelm = ET.SubElement(svg, "g") # Put it in a group + self.node.draw(x + xshift, y + yshift, svgelm) return svgelm def svg(self) -> str: - ''' Get expression as SVG string ''' - return ET.tostring(self.svgxml(), encoding='unicode') + """Get expression as SVG string""" + return ET.tostring(self.svgxml(), encoding="unicode") def save(self, fname): - ''' Save expression to SVG file ''' - with open(fname, 'w') as f: + """Save expression to SVG file""" + with open(fname, "w") as f: f.write(self.svg()) def _repr_svg_(self): - ''' Jupyter SVG representation ''' + """Jupyter SVG representation""" return self.svg() def mathmlstr(self) -> str: - ''' Get MathML as string ''' - return ET.tostring(self.mathml).decode('utf-8') + """Get MathML as string""" + return ET.tostring(self.mathml).decode("utf-8") @classmethod - def mathml2svg(cls, mathml: Union[str, ET.Element], - size: Optional[float] = None, font: Optional[str] = None): - ''' Shortcut to just return SVG string directly ''' + def mathml2svg( + cls, + mathml: Union[str, ET.Element], + size: Optional[float] = None, + font: Optional[str] = None, + ): + """Shortcut to just return SVG string directly""" return cls(mathml, size=size, font=font).svg() def getsize(self) -> tuple[float, float]: - ''' Get size of rendered text ''' - return (self.node.bbox.xmax - self.node.bbox.xmin, - self.node.bbox.ymax - self.node.bbox.ymin) + """Get size of rendered text""" + return ( + self.node.bbox.xmax - self.node.bbox.xmin, + self.node.bbox.ymax - self.node.bbox.ymin, + ) def getyofst(self) -> float: - ''' Y-shift from bottom of bbox to 0 ''' + """Y-shift from bottom of bbox to 0""" return self.node.bbox.ymin class Latex(Math): - ''' Render Math from LaTeX - - Args: - latex: Latex string - size: Base font size - mathstyle: Style parameter for math, equivalent to "mathvariant" MathML attribute - font: Font file name - color: Color parameter, equivalent to "mathcolor" attribute - inline: Use inline math mode (default is block mode) - title: Text for title alt-text tag in the SVG - ''' - def __init__(self, latex: str, size: Optional[float] = None, mathstyle: Optional[str] = None, - font: Optional[str] = None, color: Optional[str] = None, inline: bool = False, - title: Optional[str] = None, - number: Optional[str] = None): + """Render Math from LaTeX + + Args: + latex: Latex string + size: Base font size + mathstyle: Style parameter for math, equivalent to "mathvariant" MathML attribute + font: Font file name + color: Color parameter, equivalent to "mathcolor" attribute + inline: Use inline math mode (default is block mode) + title: Text for title alt-text tag in the SVG + """ + + def __init__( + self, + latex: str, + size: Optional[float] = None, + mathstyle: Optional[str] = None, + font: Optional[str] = None, + color: Optional[str] = None, + inline: bool = False, + title: Optional[str] = None, + number: Optional[str] = None, + ): self.latex = latex if number is not None: number = EqNumbering.text(number) - elif (tags := re.findall(r'\\tag\{(.*)\}', self.latex)): + elif tags := re.findall(r"\\tag\{(.*)\}", self.latex): if len(tags) > 1: - raise ValueError(r'Multiple \tag') + raise ValueError(r"Multiple \tag") number = EqNumbering.text(tags[0]) - self.latex = re.sub(r'\\tag\{(.*)\}', '', self.latex) + self.latex = re.sub(r"\\tag\{(.*)\}", "", self.latex) mathml: Union[str, ET.Element] mathml = tex2mml(self.latex, inline=inline) if mathstyle: mathml = ET.fromstring(mathml) - mathml.attrib['mathvariant'] = mathstyle - mathml = ET.tostring(mathml, encoding='unicode') + mathml.attrib["mathvariant"] = mathstyle + mathml = ET.tostring(mathml, encoding="unicode") if color: mathml = ET.fromstring(mathml) - mathml.attrib['mathcolor'] = color + mathml.attrib["mathcolor"] = color super().__init__(mathml, size, font, title=title, number=number) class Text: - ''' Mixed text and latex math. Inline math delimited by single $..$, and - display-mode math delimited by double $$...$$. Can contain multiple - lines. Drawn to SVG. - - Args: - s: string to write - textfont: font filename or family name for text - mathfont: font filename for math - mathstyle: Style parameter for math - size: font size in points - linespacing: spacing between lines - color: color of text - halign: horizontal alignment - valign: vertical alignment - rotation: Rotation angle in degrees - rotation_mode: Either 'default' or 'anchor', to - mimic Matplotlib behavoir. See: - https://matplotlib.org/stable/gallery/text_labels_and_annotations/demo_text_rotation_mode.html - title: Text for title alt-text tag in the SVG - ''' - def __init__(self, s, textfont: Optional[str] = None, mathfont: Optional[str] = None, - mathstyle: Optional[str] = None, size: Optional[float] = None, linespacing: Optional[float] = None, - color: Optional[str] = None, - halign: str = 'left', valign: str = 'base', - rotation: float = 0, rotation_mode: str = 'anchor', - title: Optional[str] = None): + """Mixed text and latex math. Inline math delimited by single $..$, and + display-mode math delimited by double $$...$$. Can contain multiple + lines. Drawn to SVG. + + Args: + s: string to write + textfont: font filename or family name for text + mathfont: font filename for math + mathstyle: Style parameter for math + size: font size in points + linespacing: spacing between lines + color: color of text + halign: horizontal alignment + valign: vertical alignment + rotation: Rotation angle in degrees + rotation_mode: Either 'default' or 'anchor', to + mimic Matplotlib behavoir. See: + https://matplotlib.org/stable/gallery/text_labels_and_annotations/demo_text_rotation_mode.html + title: Text for title alt-text tag in the SVG + """ + + def __init__( + self, + s, + textfont: Optional[str] = None, + mathfont: Optional[str] = None, + mathstyle: Optional[str] = None, + size: Optional[float] = None, + linespacing: Optional[float] = None, + color: Optional[str] = None, + halign: str = "left", + valign: str = "base", + rotation: float = 0, + rotation_mode: str = "anchor", + title: Optional[str] = None, + ): self.str = s self.mathfont = mathfont self.mathstyle = mathstyle @@ -421,7 +490,7 @@ def __init__(self, s, textfont: Optional[str] = None, mathfont: Optional[str] = # If style type, use Stix font variation textfont = textfont if textfont else config.text.textfont textstyle = config.text.variant - if loadedtextfonts.get(textfont) == 'notfound': # type: ignore + if loadedtextfonts.get(textfont) == "notfound": # type: ignore self.textfont = None self.textstyle = textfont elif textfont is None: @@ -436,110 +505,135 @@ def __init__(self, s, textfont: Optional[str] = None, mathfont: Optional[str] = loadedtextfonts[str(textfont)] = self.textfont except FileNotFoundError: # Mark as not found to not search again - loadedtextfonts[textfont] = 'notfound' # type: ignore + loadedtextfonts[textfont] = "notfound" # type: ignore self.textfont = None self.textstyle = textfont def svg(self) -> str: - ''' Get expression as SVG string ''' - return ET.tostring(self.svgxml(), encoding='unicode') + """Get expression as SVG string""" + return ET.tostring(self.svgxml(), encoding="unicode") def _repr_svg_(self): - ''' Jupyter SVG representation ''' + """Jupyter SVG representation""" return self.svg() def svgxml(self) -> ET.Element: - ''' Get standalone SVG of expression as XML Element Tree ''' - svg = ET.Element('svg') + """Get standalone SVG of expression as XML Element Tree""" + svg = ET.Element("svg") if self.title is not None: - title = ET.SubElement(svg, 'title') + title = ET.SubElement(svg, "title") title.text = self.title _, (x1, x2, y1, y2) = self._drawon(svg) - svg.attrib['width'] = fmt(x2-x1) - svg.attrib['height'] = fmt(y2-y1) - svg.attrib['xmlns'] = 'http://www.w3.org/2000/svg' + svg.attrib["width"] = fmt(x2 - x1) + svg.attrib["height"] = fmt(y2 - y1) + svg.attrib["xmlns"] = "http://www.w3.org/2000/svg" if not config.svg2: - svg.attrib['xmlns:xlink'] = 'http://www.w3.org/1999/xlink' - svg.attrib['viewBox'] = f'{fmt(x1)} {fmt(y1)} {fmt(x2-x1)} {fmt(y2-y1)}' + svg.attrib["xmlns:xlink"] = "http://www.w3.org/1999/xlink" + svg.attrib["viewBox"] = f"{fmt(x1)} {fmt(y1)} {fmt(x2-x1)} {fmt(y2-y1)}" return svg def save(self, fname): - ''' Save expression to SVG file ''' - with open(fname, 'w') as f: + """Save expression to SVG file""" + with open(fname, "w") as f: f.write(self.svg()) - def drawon(self, svg: ET.Element, x: float = 0, y: float = 0, - halign: Optional[str] = None, valign: Optional[str] = None) -> ET.Element: - ''' Draw text on existing SVG element - - Args: - svg: Element to draw on - x: x-position - y: y-position - halign: Horizontal alignment - valign: Vertical alignment - ''' + def drawon( + self, + svg: ET.Element, + x: float = 0, + y: float = 0, + halign: Optional[str] = None, + valign: Optional[str] = None, + ) -> ET.Element: + """Draw text on existing SVG element + + Args: + svg: Element to draw on + x: x-position + y: y-position + halign: Horizontal alignment + valign: Vertical alignment + """ svgelm, _ = self._drawon(svg, x, y, halign, valign) return svgelm - def _drawon(self, svg: ET.Element, x: float = 0, y: float = 0, - halign: Optional[str] = None, valign: Optional[str] = None) -> Tuple[ET.Element, Tuple[float, float, float, float]]: - ''' Draw text on existing SVG element - - Args: - svg: Element to draw on - x: x-position - y: y-position - halign: Horizontal alignment - valign: Vertical alignment - ''' + def _drawon( + self, + svg: ET.Element, + x: float = 0, + y: float = 0, + halign: Optional[str] = None, + valign: Optional[str] = None, + ) -> Tuple[ET.Element, Tuple[float, float, float, float]]: + """Draw text on existing SVG element + + Args: + svg: Element to draw on + x: x-position + y: y-position + halign: Horizontal alignment + valign: Vertical alignment + """ halign = self._halign if halign is None else halign valign = self._valign if valign is None else valign lines = self.str.splitlines() svglines = [] - svgelm = ET.SubElement(svg, 'g') + svgelm = ET.SubElement(svg, "g") # Split into lines and "parts" linesizes = [] for line in lines: svgparts = [] - parts = re.split(r'(\$+.*?\$+)', line) + parts = re.split(r"(\$+.*?\$+)", line) partsizes = [] for part in parts: if not part: continue - if part.startswith('$$') and part.endswith('$$'): # Display-mode math - math = Math.fromlatex(part.replace('$', ''), - font=self.mathfont, - mathstyle=self.mathstyle, - inline=False, - size=self.size, color=self.color) + if part.startswith("$$") and part.endswith("$$"): # Display-mode math + math = Math.fromlatex( + part.replace("$", ""), + font=self.mathfont, + mathstyle=self.mathstyle, + inline=False, + size=self.size, + color=self.color, + ) svgparts.append(math) partsizes.append(math.getsize()) - elif part.startswith('$') and part.endswith('$'): # Text-mode Math - math = Math.fromlatex(part.replace('$', ''), - font=self.mathfont, - mathstyle=self.mathstyle, - inline=True, - size=self.size, color=self.color) + elif part.startswith("$") and part.endswith("$"): # Text-mode Math + math = Math.fromlatex( + part.replace("$", ""), + font=self.mathfont, + mathstyle=self.mathstyle, + inline=True, + size=self.size, + color=self.color, + ) svgparts.append(math) partsizes.append(math.getsize()) else: # Text - part = part.replace('<', '<').replace('>', '>') + part = part.replace("<", "<").replace(">", ">") if self.textfont: # A specific font file is defined, use ziafont and ignore textstyle - txt = zf.Text(part, font=self.textfont, size=self.size, color=self.textcolor) + txt = zf.Text( + part, + font=self.textfont, + size=self.size, + color=self.textcolor, + ) partsizes.append(txt.getsize()) svgparts.append(txt) else: # use math font with textstyle - txt = Math.fromlatex(f'\\text{{{part}}}', - font=self.mathfont, - mathstyle=self.textstyle, - size=self.size, - color=self.textcolor) + txt = Math.fromlatex( + f"\\text{{{part}}}", + font=self.mathfont, + mathstyle=self.textstyle, + size=self.size, + color=self.textcolor, + ) partsizes.append(txt.getsize()) svgparts.append(txt) @@ -551,12 +645,21 @@ def _drawon(self, svg: ET.Element, x: float = 0, y: float = 0, lineheights = [max(p[1] for p in line) for line in linesizes] linewidths = [sum(p[0] for p in line) for line in linesizes] - if valign == 'bottom': - ystart = y - (len(lines)-1)*self.size*self.linespacing + lineofsts[-1] - elif valign == 'top': + if valign == "bottom": + ystart = y - (len(lines) - 1) * self.size * self.linespacing + lineofsts[-1] + elif valign == "top": ystart = y + lineheights[0] + lineofsts[0] - elif valign == 'center': - ystart = y + (lineheights[0] + lineofsts[0] - (len(lines)-1)*self.size*self.linespacing + lineofsts[-1])/2 + elif valign == "center": + ystart = ( + y + + ( + lineheights[0] + + lineofsts[0] + - (len(lines) - 1) * self.size * self.linespacing + + lineofsts[-1] + ) + / 2 + ) else: # 'base' ystart = y @@ -565,12 +668,14 @@ def _drawon(self, svg: ET.Element, x: float = 0, y: float = 0, yloc = ystart for i, line in enumerate(svglines): xloc = x - xloc += {'left': 0, - 'right': -linewidths[i], - 'center': -linewidths[i]/2}.get(halign, 0) + xloc += { + "left": 0, + "right": -linewidths[i], + "center": -linewidths[i] / 2, + }.get(halign, 0) xmin = min(xmin, xloc) - xmax = max(xmax, xloc+linewidths[i]) + xmax = max(xmax, xloc + linewidths[i]) for part, size in zip(line, linesizes[i]): part.drawon(svgelm, xloc, yloc) @@ -578,72 +683,80 @@ def _drawon(self, svg: ET.Element, x: float = 0, y: float = 0, yloc += self.size * self.linespacing ymin = y - lineheights[0] - lineofsts[0] - ymax = yloc - self.size*self.linespacing - lineofsts[-1] + ymax = yloc - self.size * self.linespacing - lineofsts[-1] if self.rotation: costh = cos(radians(self.rotation)) sinth = sin(radians(self.rotation)) - p1 = (xmin-x, ymin-y) # Corners relative to rotation point - p2 = (xmax-x, ymin-y) - p3 = (xmax-x, ymax-y) - p4 = (xmin-x, ymax-y) - x1 = x + (p1[0]*costh + p1[1]*sinth) - x2 = x + (p2[0]*costh + p2[1]*sinth) - x3 = x + (p3[0]*costh + p3[1]*sinth) - x4 = x + (p4[0]*costh + p4[1]*sinth) - y1 = y - (p1[0]*sinth - p1[1]*costh) - y2 = y - (p2[0]*sinth - p2[1]*costh) - y3 = y - (p3[0]*sinth - p3[1]*costh) - y4 = y - (p4[0]*sinth - p4[1]*costh) - bbox = (min(x1, x2, x3, x4), max(x1, x2, x3, x4), - min(y1, y2, y3, y4), max(y1, y2, y3, y4)) - - xform = '' - if self.rotation_mode == 'default': - dx = {'left': x - bbox[0], - 'right': x - bbox[1], - 'center': x - (bbox[1]+bbox[0])/2}.get(halign, 0) - dy = {'top': y - bbox[2], - 'bottom': y - bbox[3], - 'base': -sinth*dx, - 'center': y - (bbox[3]+bbox[2])/2}.get(valign, 0) - xform = f'translate({dx} {dy})' - bbox = (bbox[0]+dx, bbox[1]+dx, - bbox[2]+dy, bbox[3]+dy) - - xform += f' rotate({-self.rotation} {x} {y})' + p1 = (xmin - x, ymin - y) # Corners relative to rotation point + p2 = (xmax - x, ymin - y) + p3 = (xmax - x, ymax - y) + p4 = (xmin - x, ymax - y) + x1 = x + (p1[0] * costh + p1[1] * sinth) + x2 = x + (p2[0] * costh + p2[1] * sinth) + x3 = x + (p3[0] * costh + p3[1] * sinth) + x4 = x + (p4[0] * costh + p4[1] * sinth) + y1 = y - (p1[0] * sinth - p1[1] * costh) + y2 = y - (p2[0] * sinth - p2[1] * costh) + y3 = y - (p3[0] * sinth - p3[1] * costh) + y4 = y - (p4[0] * sinth - p4[1] * costh) + bbox = ( + min(x1, x2, x3, x4), + max(x1, x2, x3, x4), + min(y1, y2, y3, y4), + max(y1, y2, y3, y4), + ) + + xform = "" + if self.rotation_mode == "default": + dx = { + "left": x - bbox[0], + "right": x - bbox[1], + "center": x - (bbox[1] + bbox[0]) / 2, + }.get(halign, 0) + dy = { + "top": y - bbox[2], + "bottom": y - bbox[3], + "base": -sinth * dx, + "center": y - (bbox[3] + bbox[2]) / 2, + }.get(valign, 0) + xform = f"translate({dx} {dy})" + bbox = (bbox[0] + dx, bbox[1] + dx, bbox[2] + dy, bbox[3] + dy) + + xform += f" rotate({-self.rotation} {x} {y})" if config.debug.bbox: - rect = ET.SubElement(svg, 'rect') - rect.attrib['x'] = fmt(bbox[0]) - rect.attrib['y'] = fmt(bbox[2]) - rect.attrib['width'] = fmt(bbox[1]-bbox[0]) - rect.attrib['height'] = fmt(bbox[3]-bbox[2]) - rect.attrib['fill'] = 'none' - rect.attrib['stroke'] = 'red' - - svgelm.set('transform', xform) + rect = ET.SubElement(svg, "rect") + rect.attrib["x"] = fmt(bbox[0]) + rect.attrib["y"] = fmt(bbox[2]) + rect.attrib["width"] = fmt(bbox[1] - bbox[0]) + rect.attrib["height"] = fmt(bbox[3] - bbox[2]) + rect.attrib["fill"] = "none" + rect.attrib["stroke"] = "red" + if config.svg_classes: + rect.set("class", "bbox") + svgelm.set("transform", xform) xmin, xmax, ymin, ymax = bbox return svgelm, (xmin, xmax, ymin, ymax) def getsize(self) -> tuple[float, float]: - ''' Get pixel width and height of Text. ''' - svg = ET.Element('svg') + """Get pixel width and height of Text.""" + svg = ET.Element("svg") _, (xmin, xmax, ymin, ymax) = self._drawon(svg) - return (xmax-xmin, ymax-ymin) + return (xmax - xmin, ymax - ymin) def bbox(self) -> tuple[float, float, float, float]: - ''' Get bounding box (xmin, xmax, ymin, ymax) of Text. ''' - svg = ET.Element('svg') + """Get bounding box (xmin, xmax, ymin, ymax) of Text.""" + svg = ET.Element("svg") _, (xmin, xmax, ymin, ymax) = self._drawon(svg) return (xmin, xmax, ymin, ymax) def getyofst(self) -> float: - ''' Y offset from baseline to bottom of bounding box ''' + """Y offset from baseline to bottom of bounding box""" return -self.bbox()[3] # Cache the loaded fonts to prevent reloading all the time -with pkg_resources.path('ziamath.fonts', 'STIXTwoMath-Regular.ttf') as p: +with pkg_resources.path("ziamath.fonts", "STIXTwoMath-Regular.ttf") as p: fontname = p -loadedfonts: Dict[str, MathFont] = {'default': MathFont(fontname)} +loadedfonts: Dict[str, MathFont] = {"default": MathFont(fontname)} loadedtextfonts: Dict[str, zf.Font] = {}