mathjax / MathJax

Beautiful and accessible math in all browsers
http://www.mathjax.org/
Apache License 2.0
10.09k stars 1.16k forks source link

MJ3: <msubsup>'s subscript and superscript are in the middle, not bottom/top #3049

Open AlexEdgcomb opened 1 year ago

AlexEdgcomb commented 1 year ago

Issue Summary

In MJ3, <msubsup>'s subscript does not align to the bottom of the base. Similarly, <msubsup>'s superscript does not align to the top of the base.

https://developer.mozilla.org/en-US/docs/Web/MathML/Element/msubsup

Steps to Reproduce:

  1. Go to https://codepen.io/alexedgcomb/pen/zYmmXwo

Observed: Subscript a and superscript b are in the middle

image

Expected: Subscript a to be at the bottom and superscript b to be at the top, like in MJ2: https://codepen.io/alexedgcomb/pen/GRYYLwe

image

Technical details:

I am NOT using a following MathJax configuration and loading MathJax via

<script type="text/javascript" src="http://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-mml-svg.js"></script>

Supporting information:

MJ4 has the same issue: https://codepen.io/alexedgcomb/pen/rNqqbmL

MathML in Firefox looks as expected: https://codepen.io/alexedgcomb/pen/ExddJKg. Chrome doesn't.

dpvc commented 1 year ago

Thanks for the report. The TeX rules for placing super- and subscripts specify that if the base is a single character, then its height and depth are to be ignored (so that super-and subscripts all align if used on different letters), which is what is causing this problem. Because TeX doesn't treat stretchy characters in the same way as MathML, MathJax has to make some exceptions to this rule (e.g., for large operators like \sum and \int), but it should also make exceptions for stretched characters like this.

I will make a PR to resolve the issue.

dpvc commented 1 year ago

Here is a configuration you can use as a work-around for now until the fix is released.

MathJax = {
  startup: {
    ready() {
      baseCharZero = (n) => {
        const largeop = !!this.baseCore.node.attributes.get('largeop');
        const sized = !!(this.baseCore.node.isKind('mo') && this.baseCore.size);
        const scale = this.baseScale;
        return (this.baseIsChar && !largeop && !sized && scale === 1 ? 0 : n);
      };
      const {ChtmlScriptbase} = MathJax._.output?.chtml?.Wrappers?.scriptbase;
      if (ChtmlScriptbase) ChtmlScriptbase.prototype.baseCharZero = baseCharZero;
      const {SvgScriptbase} = MathJax._.output?.svg?.Wrappers?.scriptbase;
      if (SvgScriptbase) SvgScriptbase.prototype.baseCharZero = baseCharZero;
    }
  }
};
AlexEdgcomb commented 1 year ago

@dpvc , thank you for explaining!

The workaround doesn't typeset due to JS errors: https://codepen.io/alexedgcomb/pen/VwEVPme

I tried fixing, but failed: https://codepen.io/alexedgcomb/pen/bGmQgqY

MathJax = {
  startup: {
    ready() {
      baseCharZero = (n) => {
        const largeop = !!this.baseCore.node.attributes.get('largeop');
        const sized = !!(this.baseCore.node.isKind('mo') && this.baseCore.size);
        const scale = this.baseScale;
        return (this.baseIsChar && !largeop && !sized && scale === 1 ? 0 : n);
      };
      const {ChtmlScriptbase} = MathJax._.output?.chtml?.Wrappers?.scriptbase ?? {};
      if (ChtmlScriptbase) ChtmlScriptbase.prototype.baseCharZero = baseCharZero;
      const {SvgScriptbase} = MathJax._.output?.svg?.Wrappers?.scriptbase ?? {};
      if (SvgScriptbase) SvgScriptbase.prototype.baseCharZero = baseCharZero;
      MathJax.startup.defaultReady();
    }
  }
};

Changes:

dpvc commented 1 year ago

Thanks for the corrections. I was working too quickly, and was doing my testing in Firefox, and without the defaultReady() call, MathJax didn't run, and Firefox's native MathML did the rendering, and I didn't realize it and though it was fixed. Sorry about that.

In addition to the two errors you pointed out, I was working from the v4 code, which has normalized the names of the internal objects like ChtmlScriptbase, but in v3 it should be CHTMLscriptbase. Also, the replacement function must be a function (n) {...} not (n) => {...} in order to have the correct this value.

Here is a corrected (working) version:

MathJax = {
  startup: {
    ready() {
      baseCharZero = function (n) {
        const largeop = !!this.baseCore.node.attributes.get('largeop');
        const sized = !!(this.baseCore.node.isKind('mo') && this.baseCore.size);
        const scale = this.baseScale;
        return (this.baseIsChar && !largeop && !sized && scale === 1 ? 0 : n);
      };
      const CHTMLscriptbase = MathJax._.output?.chtml?.Wrappers?.scriptbase?.CHTMLscriptbase;
      if (CHTMLscriptbase) CHTMLscriptbase.prototype.baseCharZero = baseCharZero;
      const SVGscriptbase = MathJax._.output?.svg?.Wrappers?.scriptbase?.SVGscriptbase;
      if (SVGscriptbase) SVGscriptbase.prototype.baseCharZero = baseCharZero;
      MathJax.startup.defaultReady();
    }
  }
};
AlexEdgcomb commented 1 year ago

@dpvc , thank you! The v3 workaround works. I actually did happen to be interested in a v4 workaround, sorry for the confusion. I modified the v3 workaround, but I seem to be missing something: https://codepen.io/alexedgcomb/pen/gOBQQGm

dpvc commented 1 year ago

OK, for v4.0.0-alpha.1, there is a little more to be done. There is a bug where a stretchy base doesn't get its bounding box updated when it is stretched, and that needs to be fixed as well. I was working in the current development branch where that is already fixed, but for the alpha release, that also needs to be patched.

Here is an updated configuration for v4.0.0-alpha.1 with SVG output.

MathJax = {
  startup: {
    ready() {
      const SvgScriptbase = MathJax._.output.svg.Wrappers.scriptbase.SvgScriptbase.prototype;
      SvgScriptbase.baseCharZero = function (n) {
        const largeop = !!this.baseCore.node.attributes.get('largeop');
        const sized = !!(this.baseCore.node.isKind('mo') && this.baseCore.size);
        const scale = this.baseScale;
        return (this.baseIsChar && !largeop && !sized && scale === 1 ? 0 : n);
      };
      const SvgMo = MathJax._.output.svg.Wrappers.mo.SvgMo.prototype;
      Object.assign(SvgMo, {
        _setDelimSize: SvgMo.setDelimSize,
        setDelimSize(c, i) {
          this._setDelimSize.call(this, c, i);
          this.childNodes[0].invalidateBBox();
        }
      });
      MathJax.startup.defaultReady();
    }
  }
};

I think that will work for you.

AlexEdgcomb commented 1 year ago

@dpvc , thank you! Yes, that works great for v4.0.0-alpha.1