diff --git a/README.md b/README.md
index e3bfa5a..7b31cbc 100644
--- a/README.md
+++ b/README.md
@@ -46,7 +46,7 @@ The font must be installed at `/usr/local/share/fonts/г/ГОСТ_тип_А.ttf`
| Field | Description |
|---|---|
-| Unit | Symbol shown in the left panel (e.g. `U`, `mA`, `%`) |
+| Unit | Symbol shown in the left panel (e.g. `U`, `mA`, `%`). Supports inline markup — see below |
| Range label | Text inside the coloured bar on the right (e.g. `5mA`) |
| Mapping | **Linear** or **Log**. Log spaces values geometrically (major ticks evenly spaced, minor ticks crowding toward the high end) and requires Min and Max greater than 0 |
| Min / Max | Numeric range of the scale |
@@ -58,6 +58,18 @@ The font must be installed at `/usr/local/share/fonts/г/ГОСТ_тип_А.ttf`
Calculated numbers are rendered in fixed-point notation at 4 significant figures — never scientific (e.g. `10000`, not `1e+04`).
+### Unit markup
+
+The Unit field accepts a lightweight shorthand (no LaTeX engine — everything is drawn as glyph paths in the ГОСТ тип А font and auto-sized to fit the panel):
+
+| Markup | Result |
+|---|---|
+| `cm^2` | superscript (`^{...}` for multiple characters) |
+| `H_2O` | subscript (`_{...}` for multiple characters) |
+| `{1/2}` | stacked fraction with a horizontal rule; a bare `/` stays inline |
+
+Constructs nest: `{m/s^2}` is a fraction with a superscript in the denominator, `x^{1/2}` a superscripted fraction. A unit containing markup is always emitted as paths, even in text-output mode, since it is a composite of glyphs and a rule.
+
## Container
```bash
diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml
index 3767450..401b6f3 100644
--- a/k8s/deployment.yaml
+++ b/k8s/deployment.yaml
@@ -15,7 +15,7 @@ spec:
spec:
containers:
- name: m1730-generator
- image: git.baumann.gr/adebaumann/m1730-generator:0.76
+ image: git.baumann.gr/adebaumann/m1730-generator:0.77
imagePullPolicy: Always
ports:
- containerPort: 5000
diff --git a/web/app.py b/web/app.py
index bfaa063..955d1d3 100644
--- a/web/app.py
+++ b/web/app.py
@@ -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'', 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''
+ )
+ 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'\n{chr(10).join(parts)}\n'
+
+
+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 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' {escape(unit)}',
+ f' transform="scale({_UNIT_SCALE_X},1.0364155)">{escape(unit)}'
+ )
+
+ if use_paths:
+ parts += [unit_svg, ' ' + _text_to_paths(range_label, 184.79707, 113.6755, 3.60539)]
+ else:
+ parts += [
+ unit_svg,
f' {escape(range_label)}',
]
diff --git a/web/templates/index.html b/web/templates/index.html
index 38b7681..7119072 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -301,6 +301,7 @@