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
+13 -1
View File
@@ -46,7 +46,7 @@ The font must be installed at `/usr/local/share/fonts/г/ГОСТ_тип_А.ttf`
| Field | Description | | 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`) | | 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 | | 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 | | 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`). 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 ## Container
```bash ```bash
+1 -1
View File
@@ -15,7 +15,7 @@ spec:
spec: spec:
containers: containers:
- name: m1730-generator - name: m1730-generator
image: git.baumann.gr/adebaumann/m1730-generator:0.76 image: git.baumann.gr/adebaumann/m1730-generator:0.77
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 5000 - containerPort: 5000
+263 -12
View File
@@ -8,7 +8,7 @@ from fontTools.pens.svgPathPen import SVGPathPen
app = Flask(__name__) app = Flask(__name__)
VERSION = "0.76" VERSION = "0.77"
_LOGO_PATH = pathlib.Path(__file__).parent / 'static' / 'logo.png' _LOGO_PATH = pathlib.Path(__file__).parent / 'static' / 'logo.png'
_LOGO_B64 = base64.b64encode(_LOGO_PATH.read_bytes()).decode() _LOGO_B64 = base64.b64encode(_LOGO_PATH.read_bytes()).decode()
@@ -29,6 +29,7 @@ _GLYPH_SET = _FONT_OBJ.getGlyphSet()
_CMAP = _FONT_OBJ.getBestCmap() _CMAP = _FONT_OBJ.getBestCmap()
_HMTX = _FONT_OBJ['hmtx'].metrics _HMTX = _FONT_OBJ['hmtx'].metrics
_UPM = _FONT_OBJ['head'].unitsPerEm _UPM = _FONT_OBJ['head'].unitsPerEm
_GLYF = _FONT_OBJ['glyf']
# SVG absolute coordinate constants (1 user unit = 1 mm) # SVG absolute coordinate constants (1 user unit = 1 mm)
ZERO_X = 60.844130 ZERO_X = 60.844130
@@ -41,12 +42,252 @@ SMALL_H = 2.6683
_UNIT_AREA_WIDTH = 18.445 _UNIT_AREA_WIDTH = 18.445
_UNIT_SCALE_X = 0.96486402 _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: def _unit_font_size(unit: str) -> float:
n = max(1, len(unit)) # Fit the unit's true rendered width (measuring the parsed markup, so
return min(18.7981, _UNIT_AREA_WIDTH / (n * _UNIT_SCALE_X * _UNIT_CHAR_EM)) # 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: 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" />' ' M 58.443978,107.54692 H 183.64409 v 6.19963 H 58.443978 Z" />'
) )
if use_paths: # A unit with markup (super/subscripts, fractions) is a composite of glyph
parts += [ # paths + a rule, so it can't be a single <text> node — render it as paths,
' ' + _text_to_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), unit, 41.455711, 109.03796, _unit_font_size(unit),
wrap_transform=f'scale({_UNIT_SCALE_X},1.0364155)' wrap_transform=f'scale({_UNIT_SCALE_X},1.0364155)'
), )
' ' + _text_to_paths(range_label, 184.79707, 113.6755, 3.60539),
]
else: else:
fs = _unit_font_size(unit) fs = _unit_font_size(unit)
parts += [ unit_svg = (
f' <text style="font-size:{fs:.4f}px;font-family:\'ГОСТ тип А\',monospace;fill:#1a1a1a"' f' <text style="font-size:{fs:.4f}px;font-family:\'ГОСТ тип А\',monospace;fill:#1a1a1a"'
f' x="41.455711" y="109.03796"' 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' <text style="font-size:3.60539px;font-family:\'ГОСТ тип А\',monospace;fill:#1a1a1a"'
f' x="184.79707" y="113.6755">{escape(range_label)}</text>', f' x="184.79707" y="113.6755">{escape(range_label)}</text>',
] ]
+1
View File
@@ -301,6 +301,7 @@
<div class="field"> <div class="field">
<label>Unit</label> <label>Unit</label>
<input name="unit" type="text" value="%" required> <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>
<div class="field"> <div class="field">
<label>Range label</label> <label>Range label</label>