Add unit-label markup: super/subscripts and stacked fractions

Lighter shorthand (^x, _x, {a/b}) rendered as glyph paths in the ГОСТ
font — no LaTeX engine, so typography and self-contained output are
preserved. Constructs nest (e.g. {m/s^2}, x^{1/2}). A marked-up unit is
sized to its true ink bounding box and centered in the unit box with
white space, fitting in both axes so tall fractions don't overflow.

Also fit plain units by measured glyph width rather than a per-character
estimate. Bump to 0.77.
This commit is contained in:
2026-07-14 16:37:35 +02:00
parent edeb696238
commit f676651edb
4 changed files with 280 additions and 16 deletions
+265 -14
View File
@@ -8,7 +8,7 @@ from fontTools.pens.svgPathPen import SVGPathPen
app = Flask(__name__)
VERSION = "0.76"
VERSION = "0.77"
_LOGO_PATH = pathlib.Path(__file__).parent / 'static' / 'logo.png'
_LOGO_B64 = base64.b64encode(_LOGO_PATH.read_bytes()).decode()
@@ -29,6 +29,7 @@ _GLYPH_SET = _FONT_OBJ.getGlyphSet()
_CMAP = _FONT_OBJ.getBestCmap()
_HMTX = _FONT_OBJ['hmtx'].metrics
_UPM = _FONT_OBJ['head'].unitsPerEm
_GLYF = _FONT_OBJ['glyf']
# SVG absolute coordinate constants (1 user unit = 1 mm)
ZERO_X = 60.844130
@@ -41,12 +42,252 @@ SMALL_H = 2.6683
_UNIT_AREA_WIDTH = 18.445
_UNIT_SCALE_X = 0.96486402
_UNIT_CHAR_EM = 0.65
_UNIT_MAX_FS = 18.7981
# --- Inline markup for the unit label -------------------------------------
# Lighter-shorthand notation, rendered with the ГОСТ font (no LaTeX engine):
# ^x / ^{...} superscript _x / _{...} subscript
# {a/b} stacked fraction (vinculum); a bare '/' stays inline
# The `{...}` argument after ^/_ and the two sides of a fraction are parsed
# recursively, so constructs nest (e.g. {m/s^2}, x^{1/2}).
_SUP_SCALE = 0.62 # size of a super/subscript relative to its base
_SUP_RISE = 0.36 # superscript baseline lift, in base ems
_SUB_DROP = 0.14 # subscript baseline drop, in base ems
_FRAC_SCALE = 0.62 # size of numerator/denominator relative to fraction
_FRAC_AXIS = 0.32 # vinculum height above the base baseline, in base ems
_FRAC_GAP = 0.14 # gap between numerator/denominator ink and the vinculum
_FRAC_SIDE = 0.12 # vinculum overhang past the wider side, in sub ems
# Target box for the unit label (absolute panel coords). A marked-up unit is
# scaled to fit and centered inside this box, leaving white space around it.
# The panel spans y 90.55..117.05; the scale bar starts at x 58.44.
_UNIT_BOX_X0, _UNIT_BOX_X1 = 39.6, 57.4
_UNIT_BOX_Y0, _UNIT_BOX_Y1 = 92.8, 114.8
def _glyph_svg(ch: str, x: float, y: float, fs: float, fill: str):
"""Return (svg_path_or_None, advance_width) for one glyph at size `fs`."""
gn = _CMAP.get(ord(ch))
if gn is None:
return None, 0.0
s = fs / _UPM
adv = _HMTX.get(gn, (0, 0))[0] * s
pen = SVGPathPen(_GLYPH_SET)
_GLYPH_SET[gn].draw(pen)
d = pen.getCommands()
if not d:
return None, adv
t = f'translate({x:.5f},{y:.5f}) scale({s:.8f},{-s:.8f})'
return f'<path style="fill:{fill}" transform="{t}" d="{d}"/>', adv
def _glyph_ybounds(ch: str):
"""(yMin, yMax) of a glyph's ink in em units, y-up. (0, 0) if it has none."""
gn = _CMAP.get(ord(ch))
if gn is None:
return 0.0, 0.0
g = _GLYF[gn]
if g.numberOfContours == 0: # space and other empty glyphs
return 0.0, 0.0
g.recalcBounds(_GLYF)
return g.yMin / _UPM, g.yMax / _UPM
def _extract_braced(s: str, i: int):
"""s[i] == '{'; return (inner_text, index_just_after_matching_'}')."""
depth, j = 1, i + 1
while j < len(s) and depth:
if s[j] == '{':
depth += 1
elif s[j] == '}':
depth -= 1
if depth == 0:
break
j += 1
return s[i + 1:j], j + 1
def _parse_braced(inner: str):
"""A '{...}' group: a top-level '/' makes it a fraction, else a plain run."""
depth = 0
for k, ch in enumerate(inner):
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
elif ch == '/' and depth == 0:
return ('frac', _parse_markup(inner[:k]), _parse_markup(inner[k + 1:]))
return ('group', _parse_markup(inner))
def _parse_atom(s: str, i: int):
"""Argument of ^ or _: a braced group or a single character."""
if i >= len(s):
return [], i
if s[i] == '{':
inner, j = _extract_braced(s, i)
return [_parse_braced(inner)], j
return [('lit', s[i])], i + 1
def _parse_markup(s: str):
"""Parse a markup string into a flat list of nodes."""
nodes, i = [], 0
while i < len(s):
c = s[i]
if c in '^_':
atom, i = _parse_atom(s, i + 1)
nodes.append(('sup' if c == '^' else 'sub', atom))
elif c == '{':
inner, j = _extract_braced(s, i)
nodes.append(_parse_braced(inner))
i = j
else:
nodes.append(('lit', c))
i += 1
return nodes
def _measure(nodes, fs: float) -> float:
w = 0.0
for n in nodes:
if n[0] == 'lit':
gn = _CMAP.get(ord(n[1]))
if gn:
w += _HMTX.get(gn, (0, 0))[0] / _UPM * fs
elif n[0] == 'group':
w += _measure(n[1], fs)
elif n[0] in ('sup', 'sub'):
w += _measure(n[1], fs * _SUP_SCALE)
elif n[0] == 'frac':
fs2 = fs * _FRAC_SCALE
w += max(_measure(n[1], fs2), _measure(n[2], fs2)) + 2 * _FRAC_SIDE * fs2
return w
def _frac_layout(n, fs: float):
"""Fraction geometry (offsets relative to the base baseline, y-down).
Numerator and denominator are placed so their ink clears the vinculum by
`_FRAC_GAP` on each side — using the parts' real ink extents, so the gaps
hold whatever glyphs (or nested fractions) they contain.
"""
fs2 = fs * _FRAC_SCALE
axis = -_FRAC_AXIS * fs
_, num_bot = _vbounds(n[1], fs2) # numerator ink bottom, baseline-relative
num_base = axis - _FRAC_GAP * fs2 - num_bot
den_top, _ = _vbounds(n[2], fs2) # denominator ink top, baseline-relative
den_base = axis + _FRAC_GAP * fs2 - den_top
return fs2, axis, num_base, den_base
def _vbounds(nodes, fs: float):
"""(top, bottom) of the composite ink, baseline-relative, y-down (top < bottom)."""
lo, hi = math.inf, -math.inf
for n in nodes:
if n[0] == 'lit':
ymin, ymax = _glyph_ybounds(n[1])
a, b = -ymax * fs, -ymin * fs
elif n[0] == 'group':
a, b = _vbounds(n[1], fs)
elif n[0] == 'sup':
a, b = _vbounds(n[1], fs * _SUP_SCALE)
a, b = a - _SUP_RISE * fs, b - _SUP_RISE * fs
elif n[0] == 'sub':
a, b = _vbounds(n[1], fs * _SUP_SCALE)
a, b = a + _SUB_DROP * fs, b + _SUB_DROP * fs
elif n[0] == 'frac':
fs2, axis, num_base, den_base = _frac_layout(n, fs)
nt, nb = _vbounds(n[1], fs2)
dt, db = _vbounds(n[2], fs2)
a = min(nt + num_base, axis)
b = max(db + den_base, axis)
else:
continue
lo, hi = min(lo, a), max(hi, b)
return (0.0, 0.0) if lo > hi else (lo, hi)
def _draw_nodes(nodes, x: float, y: float, fs: float, fill: str):
"""Return (list_of_svg_parts, total_advance_width). y is the base baseline."""
parts, cx = [], x
for n in nodes:
if n[0] == 'lit':
p, adv = _glyph_svg(n[1], cx, y, fs, fill)
if p:
parts.append(p)
cx += adv
elif n[0] == 'group':
p, adv = _draw_nodes(n[1], cx, y, fs, fill)
parts += p
cx += adv
elif n[0] in ('sup', 'sub'):
fs2 = fs * _SUP_SCALE
y2 = y - _SUP_RISE * fs if n[0] == 'sup' else y + _SUB_DROP * fs
p, adv = _draw_nodes(n[1], cx, y2, fs2, fill)
parts += p
cx += adv
elif n[0] == 'frac':
fs2, axis, num_base, den_base = _frac_layout(n, fs)
wn, wd = _measure(n[1], fs2), _measure(n[2], fs2)
bar_w = max(wn, wd) + 2 * _FRAC_SIDE * fs2
pn, _ = _draw_nodes(n[1], cx + (bar_w - wn) / 2, y + num_base, fs2, fill)
pd, _ = _draw_nodes(n[2], cx + (bar_w - wd) / 2, y + den_base, fs2, fill)
parts += pn + pd
ay = y + axis
parts.append(
f'<line x1="{cx:.5f}" y1="{ay:.5f}" x2="{cx + bar_w:.5f}" y2="{ay:.5f}"'
f' style="stroke:{fill};stroke-width:{fs * 0.055:.5f};stroke-linecap:butt" />'
)
cx += bar_w
return parts, cx - x
def _has_markup(s: str) -> bool:
return any(c in s for c in '^_') or ('{' in s and '}' in s)
def _markup_unit_svg(unit: str, fill='#1a1a1a') -> str:
"""Render a marked-up unit, scaled to fit and centered in the unit box."""
nodes = _parse_markup(unit)
x0, x1, y0, y1 = _UNIT_BOX_X0, _UNIT_BOX_X1, _UNIT_BOX_Y0, _UNIT_BOX_Y1
xc, yc = (x0 + x1) / 2, (y0 + y1) / 2
# Fit to the box in both axes (width is horizontally condensed by SCALE_X).
w1 = _measure(nodes, 1.0)
top1, bot1 = _vbounds(nodes, 1.0)
fs = _UNIT_MAX_FS
if w1 > 0:
fs = min(fs, (x1 - x0) / (w1 * _UNIT_SCALE_X))
if bot1 - top1 > 0:
fs = min(fs, (y1 - y0) / (bot1 - top1))
# Center: baseline placed so the ink box's mid-height lands on yc; glyphs
# drawn from a natural left edge, then condensed horizontally about xc.
w = _measure(nodes, fs)
top, bot = _vbounds(nodes, fs)
y_base = yc - (top + bot) / 2
parts, _ = _draw_nodes(nodes, xc - w / 2, y_base, fs, fill)
cond = f'translate({xc:.5f},0) scale({_UNIT_SCALE_X},1) translate({-xc:.5f},0)'
return f'<g transform="{cond}">\n{chr(10).join(parts)}\n</g>'
def _text_em_width(text: str) -> float:
"""Advance width of `text` in em units, from the font's own metrics."""
return sum(
_HMTX.get(_CMAP.get(ord(ch)), (0, 0))[0]
for ch in text if _CMAP.get(ord(ch))
) / _UPM
def _unit_font_size(unit: str) -> float:
n = max(1, len(unit))
return min(18.7981, _UNIT_AREA_WIDTH / (n * _UNIT_SCALE_X * _UNIT_CHAR_EM))
# Fit the unit's true rendered width (measuring the parsed markup, so
# super/subscripts and fractions are accounted for) into the available
# area. Rendered width at a given size is em_width * font_size * scale_x.
em_w = _measure(_parse_markup(unit), 1.0)
if em_w <= 0:
return _UNIT_MAX_FS
return min(_UNIT_MAX_FS, _UNIT_AREA_WIDTH / (em_w * _UNIT_SCALE_X))
def _fmt(v: float) -> str:
@@ -154,20 +395,30 @@ def generate_svg(unit, min_val, max_val, range_label, big_ticks, small_ticks, la
' M 58.443978,107.54692 H 183.64409 v 6.19963 H 58.443978 Z" />'
)
if use_paths:
parts += [
' ' + _text_to_paths(
unit, 41.455711, 109.03796, _unit_font_size(unit),
wrap_transform=f'scale({_UNIT_SCALE_X},1.0364155)'
),
' ' + _text_to_paths(range_label, 184.79707, 113.6755, 3.60539),
]
# A unit with markup (super/subscripts, fractions) is a composite of glyph
# paths + a rule, so it can't be a single <text> node — render it as paths,
# scaled to fit and centered in the unit box, in both modes. A plain unit
# keeps the original baseline-anchored text/paths behaviour.
if _has_markup(unit):
unit_svg = ' ' + _markup_unit_svg(unit)
elif use_paths:
unit_svg = ' ' + _text_to_paths(
unit, 41.455711, 109.03796, _unit_font_size(unit),
wrap_transform=f'scale({_UNIT_SCALE_X},1.0364155)'
)
else:
fs = _unit_font_size(unit)
parts += [
unit_svg = (
f' <text style="font-size:{fs:.4f}px;font-family:\'ГОСТ тип А\',monospace;fill:#1a1a1a"'
f' x="41.455711" y="109.03796"'
f' transform="scale({_UNIT_SCALE_X},1.0364155)">{escape(unit)}</text>',
f' transform="scale({_UNIT_SCALE_X},1.0364155)">{escape(unit)}</text>'
)
if use_paths:
parts += [unit_svg, ' ' + _text_to_paths(range_label, 184.79707, 113.6755, 3.60539)]
else:
parts += [
unit_svg,
f' <text style="font-size:3.60539px;font-family:\'ГОСТ тип А\',monospace;fill:#1a1a1a"'
f' x="184.79707" y="113.6755">{escape(range_label)}</text>',
]
+1
View File
@@ -301,6 +301,7 @@
<div class="field">
<label>Unit</label>
<input name="unit" type="text" value="%" required>
<span style="font-family:var(--f-mono);font-size:0.65rem;color:var(--muted)">Markup: cm^2 · H_2O · {1/2} fraction · nests, e.g. {m/s^2}</span>
</div>
<div class="field">
<label>Range label</label>