How to limit mathjax to window width?

Especially on mobile, mathjax equations on my cards overflow beyond the screen width. I am looking for a solution that scales down the equation so that it fits the screen width.

I tried this:

mjx-container { font-size: 4vw !important ; }

However, there are a few problems with this.

  • It applies to both block and inline mathjax. I want something that applies to block mathjax only.
  • It doesn’t scale according to the length of the equation.
  • On large screens (desktop), the font size becomes very large.

An old related request: MathJax Long Equation Scaling

4 Likes

Both trig identity and pi formula above are mathjax blocks. The way my script works is to repetitively reduce a mathjax block font size until it fits inside the card content. it does this to all mathjax block that’s too big to fit. i tested this on Anki desktop, Ankiweb and Ankidroid, but i’m still worried it doesn’t work well on Ankidroid. I hope this sufficiently solves the issue.

Demonstration: Front Template

{{Front}}

<script>
  'use strict';
  var isAnkiweb;
  var invocation;
  var qaElm;
  var fillElm;

  function getCSSProperty(elm, prop, default_ = undefined) {
    return getComputedStyle(elm).getPropertyValue(prop) || default_;
  }

  function fitMjx(elm) {
    let right = elm.getBoundingClientRect().right;
    let size = parseFloat(getCSSProperty(elm, 'font-size'));
    const targetRight = fillElm.getBoundingClientRect().right;

    if (right <= targetRight) { return; }

    // shrink
    while (right > targetRight) {
      const diff = Math.max(0.1, Math.log10(right - targetRight));
      size -= diff * diff;
      elm.style.fontSize = `${size}px`;
      right = elm.getBoundingClientRect().right;
    }

    // grow
    size += 1;
    while (elm.getBoundingClientRect().right <= targetRight) {
      elm.style.fontSize = `${size}px`
      size += 1;
    }
    elm.style.fontSize = `${size - 2}px`;
  }
  
  function setup() {
    isAnkiweb = location.hostname.includes('ankiuser.net') || location.hostname.includes('ankiweb.net');
    if (isAnkiweb) {
      document.documentElement.classList.add('ankiweb');
    }
    qaElm = document.querySelector('#qa');
    if (isAnkiweb) {
      fillElm = qaElm;
    } else if (document.documentElement.classList.contains('mobile')) {
      fillElm = document.querySelector('#content');
    } else {
      fillElm = document.body;
    }
  }
  function main() {
    setup();
    const mathElms = qaElm.querySelectorAll('mjx-math[display="true"]');
    let dispatchDisconnect = false;
    if (mathElms.length === 0) {
      const observer = new MutationObserver((muts) => {
        for (const mut of muts) {
          if (mut.type !== "childList") { return; }
          for (const node of mut.addedNodes) {
            if (node.nodeType === 1 &&
                node.tagName === 'MJX-CONTAINER' &&
                node.getAttribute('display') === 'true') {
              if (dispatchDisconnect === false) {
                setTimeout(() => observer.disconnect(), 100);
                dispatchDisconnect = true;
              }
              fitMjx(node.querySelector('mjx-math'));
            }
          }
        }
      });
      observer.observe(qaElm, { childList: true, subtree: true });
    }
    mathElms.forEach(fitMjx);
  }
  
  if (document.querySelector('.card') !== null) {
    if (document.readyState === 'loading') {
      console.log('[Init] document is still loading, deferring to "DOMContentLoaded" event');
      invocation = 'DOMContentLoaded';
      document.addEventListener('DOMContentLoaded', main);
    } else {
      console.log('[Init] document is ready');
      invocation = 'ready';
      main();
    }
  } else {
    console.log('[Init] deferring to next macrotask');
    invocation = 'macrotask';
    setTimeout(main);
  }
</script>
<style>
  @keyframes appear {
    from {
      opacity: 0;
    }
    to {
      opacity: 1;
    }
  }
  mjx-math[display="true"] {
    animation: appear var(--math-appear-timing, 0.2s) ease-in;
  }
</style>

Demonstration: Back Template

{{FrontSide}}

<hr id=answer>

{{Back}}

Demonstration: Styling

.card {
    font-family: arial;
    font-size: 20px;
    text-align: center;
    color: black;
    background-color: white;
}

Demonstration: Fields

Front

this is front side 5. long mathjax equation<br><anki-mathjax block="true">\pi = 4 \left( 1 - \frac{1}{3} + \frac{1}{5} - \frac{1}{7} + \frac{1}{9} - \frac{1}{11} + \frac{1}{13} - \frac{1}{15} + \frac{1}{17} - \frac{1}{19} + \frac{1}{21} - \frac{1}{23} + \frac{1}{25} - \frac{1}{27} + \frac{1}{29} - \frac{1}{31} + \frac{1}{33} - \frac{1}{35} + \frac{1}{37} - \frac{1}{39} + \frac{1}{41} - \frac{1}{43} + \ldots \right)</anki-mathjax><br><anki-mathjax block="true">\sin^2 \theta + \cos^2 \theta = 1</anki-mathjax><br>

Back

this is back side 5.

Thanks for the answer.

However, this doesn’t work on mobile (tested on AnkiDroid). It also doesn’t work if I choose “Add mobile class” in Anki Desktop’s previewer. So, this is not an AnkiDroid-specific issue but likely an issue on all mobile devices (incl. AnkiMobile).

By the way, I now think that just shrinking the mathjax font is not a good idea because this makes the equations illegible and I have to zoom them to read. I think that a better solution would be to

  • decrease the font size as long as the size still remains above a certain threshold (say 8px)
  • if the smallest font is also unable to fit the screen width, then make the mathjax block horizontally scrollable so that I can scroll just the equation but not the entire card.

on Ankidroid, the scrollbar isn’t rendered at all but dragging still controls scrolling. i don’t know how to change it or why is it like that since browsers/ankiweb on android do render the scrollbar. perhaps it’s some kind of android system webview thing. either way, to solve it for ankidroid i added hints.

To set a minimum font-size, use --mjx-min-font-size. By default it’s 10pt, but you can treat it like a regular CSS length value as long as it doesn’t depend on the container element (i.e. %). For example:

.card {
    --mjx-min-font-size: 7pt;
}

All else are unchanged except for the following:

Demonstration: Front Side

i forgot to mention this before, but the font shrinking operation is run only once when loading the front/back side. that means if you change the viewport without re-rendering the entire thing, the font stays the same - it’s not “responsive”. it’s possible to implement this via resize event, but i don’t want the font shrinking thing to happen all the time as it causes reflow on each attempt. in particular, if your card have lots of mathjax, i don’t know how slow or fast it would become.

Front Side
{{Front}}

<script>
  'use strict';
  var device;
  var invocation;
  var qaElm;
  var cssEval;
  var fillElm;

  function getCSSProperty(elm, prop, default_ = undefined) {
    return getComputedStyle(elm).getPropertyValue(prop) || default_;
  }
  function computeCSSLength (v) {
    // See also: https://drafts.csswg.org/cssom/#resolved-values
    cssEval.style.height = v;
    return getComputedStyle(cssEval).height;
  }

  function hintMjxContainer(elm) {
    if (elm.scrollLeft >= 1) {
      elm.classList.add('left-hint');
    } else {
      elm.classList.remove('left-hint');
    }
    console.log(`right edge check: ${elm.scrollLeft + elm.clientWidth}px < ${elm.scrollWidth}px`)
    if (elm.scrollLeft + elm.clientWidth <= elm.scrollWidth - 2) {
      elm.classList.add('right-hint');
    } else {
      elm.classList.remove('right-hint');
    }
  }

  function fitMjx(elm) {
    let right = elm.getBoundingClientRect().right;
    let size = parseFloat(getCSSProperty(elm, 'font-size'));
    const targetRight = fillElm.getBoundingClientRect().right;
    const minSize = parseFloat(computeCSSLength(elm.hasAttribute('data-mjx-min-font-size') ?
      elm.getAttribute('data-mjx-min-font-size') :
      getCSSProperty(elm, '--mjx-min-font-size', '10pt')));
    console.log(`minSize: ${minSize}px`);

    if (right <= targetRight) { return; }

    // shrink
    while (right > targetRight) {
      const diff = Math.max(0.1, Math.log10(right - targetRight));
      size -= diff * diff;
      if (size < minSize) {
        size = minSize;
        break;
      }
      elm.style.fontSize = `${size}px`;
      right = elm.getBoundingClientRect().right;
    }

    // grow
    while (right <= targetRight) {
      size += 1;
      elm.style.fontSize = `${size}px`
      right = elm.getBoundingClientRect().right;
    }
    if (right > targetRight) {
      size = Math.max(size - 1.5, minSize);
    }
    elm.style.fontSize = `${size}px`;
    
    const container = elm.parentElement;
    hintMjxContainer(container);
    container.addEventListener('scroll', (ev) => hintMjxContainer(ev.target));
  }
  
  function setup() {
    device = 'desktop';
    let isAnkiweb = location.hostname.includes('ankiuser.net') || location.hostname.includes('ankiweb.net');
    if (isAnkiweb) {
      document.documentElement.classList.add('ankiweb');
      device = 'ankiweb';
    }
    if (document.documentElement.classList.contains('mobile')) {
      device = 'mobile';
    }

    qaElm = document.querySelector('#qa');
    fillElm = isAnkiweb ? qaElm : document.documentElement;
    cssEval = qaElm.querySelector('#css-eval');
  }
  function main() {
    setup();
    const mathElms = qaElm.querySelectorAll('mjx-math[display="true"]');
    let dispatchDisconnect = false;
    if (mathElms.length === 0) {
      const observer = new MutationObserver((muts) => {
        for (const mut of muts) {
          if (mut.type !== "childList") { return; }
          for (const node of mut.addedNodes) {
            if (node.nodeType === 1 &&
                node.tagName === 'MJX-CONTAINER' &&
                node.getAttribute('display') === 'true') {
              if (dispatchDisconnect === false) {
                setTimeout(() => observer.disconnect(), 100);
                dispatchDisconnect = true;
              }
              fitMjx(node.querySelector('mjx-math'));
            }
          }
        }
      });
      observer.observe(qaElm, { childList: true, subtree: true });
    }
    mathElms.forEach(fitMjx);
  }
  
  if (document.querySelector('.card') !== null) {
    if (document.readyState === 'loading') {
      console.log('[Init] document is still loading, deferring to "DOMContentLoaded" event');
      invocation = 'DOMContentLoaded';
      document.addEventListener('DOMContentLoaded', main);
    } else {
      console.log('[Init] document is ready');
      invocation = 'ready';
      main();
    }
  } else {
    console.log('[Init] deferring to next macrotask');
    invocation = 'macrotask';
    setTimeout(main);
  }
</script>
<style>
  @keyframes appear {
    from {
      opacity: 0;
    }
    to {
      opacity: 1;
    }
  }
  mjx-math[display="true"] {
    animation: appear var(--mjx-appear-timing, 0.2s) ease-in;
  }

  mjx-container[display="true"] {
    --scroll-hint: hsla(0, 0%, 85%, 0.2);
    overflow-x: visible;
    overflow-y: hidden;
    margin-top: 0 !important;
    margin-bottom: 0 !important;
    padding-top: 1em;
    padding-bottom: 1em;
    scrollbar-width: thin; /* "all" browsers */
  }
  .nightMode mjx-container[display="true"] {
    --scroll-hint: hsla(0, 0%, 100%, 0.2);
  }
  mjx-container[display="true"]::-webkit-scrollbar:horizontal {
    height: 7px !important; /* chromium and safari */
  }

  mjx-container[display="true"].left-hint {
    background: linear-gradient(to right in oklab, var(--scroll-hint), transparent 5%);
  }
  mjx-container[display="true"].right-hint {
    background: linear-gradient(to left in oklab, var(--scroll-hint), transparent 5%);
  }
  mjx-container[display="true"].left-hint.right-hint {
    background:
      linear-gradient(to right in oklab, var(--scroll-hint), transparent 5%),
      linear-gradient(to left in oklab, var(--scroll-hint), transparent 5%);
  }
  </style>
<div id="css-eval" hidden></div>

Demonstration: Styling

My android phone has a different gamma/contrast setting. it’s not configurable. This impacts how the scroll hint gradient is shown. I expose a way to change the hint’s color.

Styling
.card {
    font-family: arial;
    font-size: 20px;
    text-align: center;
    color: black;
    background-color: white;
}
.mobile :not(.nightMode) {
    --scroll-hint: hsla(0, 0%, 65%, 0.2) !important;
}
.mobile .nightMode {
    --scroll-hint: hsla(0, 0%, 85%, 0.2) !important;
}
1 Like

This seems to work well. Thank you so much.