Files
44-Scale/web/app.py
T

528 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import base64
import math
import pathlib
from flask import Flask, render_template, request, Response
from html import escape
from fontTools.ttLib import TTFont
from fontTools.pens.svgPathPen import SVGPathPen
app = Flask(__name__)
VERSION = "0.82"
_LOGO_PATH = pathlib.Path(__file__).parent / 'static' / 'logo.png'
_LOGO_B64 = base64.b64encode(_LOGO_PATH.read_bytes()).decode()
_FONT_PATH = pathlib.Path('/usr/local/share/fonts/г/ГОСТ_тип_А.ttf')
_FONT_B64 = base64.b64encode(_FONT_PATH.read_bytes()).decode()
_FONT_FACE = (
'<defs><style>'
'@font-face {'
'font-family:"ГОСТ тип А";'
f'src:url("data:font/truetype;base64,{_FONT_B64}") format("truetype");'
'}'
'</style></defs>'
)
_FONT_OBJ = TTFont(str(_FONT_PATH))
_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
MAX_X = 180.251230
BAR_Y = 107.54670
SCALE_LEN = MAX_X - ZERO_X # 119.4071 mm
BIG_H = 5.0895
SMALL_H = 2.6683
_UNIT_AREA_WIDTH = 18.445
_UNIT_SCALE_X = 0.96486402
_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:
# 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:
# Snap near-integers — log-scale geometric values accumulate FP error
# (e.g. 9999.9999), which would otherwise miss the integer case.
r = round(v)
if abs(v - r) < 1e-9 * max(1.0, abs(v)):
return str(int(r))
# 4 significant figures in fixed-point notation (never scientific).
exp = math.floor(math.log10(abs(v)))
decimals = max(0, 4 - 1 - exp)
s = f"{v:.{decimals}f}"
return s.rstrip('0').rstrip('.') if '.' in s else s
def _text_to_paths(text, x, y, font_size, anchor='start', fill='#1a1a1a', wrap_transform=None):
s = font_size / _UPM
if anchor == 'middle':
total_w = sum(
_HMTX.get(_CMAP.get(ord(ch)), (0, 0))[0] * s
for ch in text if _CMAP.get(ord(ch))
)
x -= total_w / 2
parts = []
cx = x
for ch in text:
gn = _CMAP.get(ord(ch))
if gn is None:
continue
pen = SVGPathPen(_GLYPH_SET)
_GLYPH_SET[gn].draw(pen)
d = pen.getCommands()
if d:
t = f'translate({cx:.5f},{y:.5f}) scale({s:.8f},{-s:.8f})'
parts.append(f'<path style="fill:{fill}" transform="{t}" d="{d}"/>')
cx += _HMTX.get(gn, (0, 0))[0] * s
inner = '\n'.join(parts)
if wrap_transform:
return f'<g transform="{wrap_transform}">{inner}</g>'
return inner
def generate_svg(unit, min_val, max_val, range_label, big_ticks, small_ticks, label_count,
use_paths=True, scale_type='linear', custom_labels=None, label_size=9):
if scale_type == 'log':
if min_val <= 0 or max_val <= 0:
raise ValueError("Logarithmic scale requires min and max values greater than 0.")
lmin, lmax = math.log(min_val), math.log(max_val)
lspan = lmax - lmin
if lspan == 0:
raise ValueError("Logarithmic scale requires min and max values to differ.")
def value_to_x(v):
return ZERO_X + SCALE_LEN * (math.log(v) - lmin) / lspan
def major_value(i):
return min_val * (max_val / min_val) ** (i / big_ticks)
elif scale_type == 'sqrt':
if min_val < 0:
raise ValueError("Square-root scale requires min value ≥ 0.")
span = max_val - min_val
if span <= 0:
raise ValueError("Square-root scale requires max value greater than min.")
def value_to_x(v):
return ZERO_X + SCALE_LEN * math.sqrt((v - min_val) / span)
def major_value(i):
return min_val + span * (i / big_ticks) ** 2
elif scale_type == 'reciprocal':
if min_val <= 0 or max_val <= 0:
raise ValueError("Reciprocal scale requires min and max values greater than 0.")
if max_val <= min_val:
raise ValueError("Reciprocal scale requires max value greater than min.")
k = 1.0 / min_val - 1.0 / max_val # always positive
def value_to_x(v):
return ZERO_X + SCALE_LEN * (1.0 / v - 1.0 / max_val) / k
def major_value(i):
return 1.0 / (1.0 / max_val + i / big_ticks * k)
else:
span = max_val - min_val
def value_to_x(v):
if span == 0:
return ZERO_X
return ZERO_X + SCALE_LEN * (v - min_val) / span
def major_value(i):
return min_val + span * i / big_ticks
big_interval = SCALE_LEN / big_ticks
# Major ticks are always physically evenly spaced; only their values differ.
big_xs = [ZERO_X + i * big_interval for i in range(big_ticks + 1)]
big_vals = [major_value(i) for i in range(big_ticks + 1)]
# Custom labels, when supplied, override the calculated numbers and set the count.
if custom_labels:
label_count = max(1, min(len(custom_labels), big_ticks + 1))
custom_labels = custom_labels[:label_count]
else:
label_count = max(2, min(label_count, big_ticks + 1))
if label_count == 1:
label_indices = [0]
else:
label_indices = [round(k * big_ticks / (label_count - 1)) for k in range(label_count)]
parts = [
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>',
'<svg width="157.18373mm" height="26.683672mm"'
' viewBox="0 0 157.18373 26.683672" version="1.1"'
' xmlns="http://www.w3.org/2000/svg">',
]
if use_paths:
parts.append(' <g transform="translate(-37.352226,-90.454928)">')
else:
parts += [_FONT_FACE, ' <g transform="translate(-37.352226,-90.454928)">']
parts.append(
' <path style="fill:#ffffff;stroke:#1a1a1a;stroke-width:0.184;'
'stroke-linecap:round;stroke-miterlimit:10"'
' d="M 37.444226,90.546928 V 117.0466 H 194.44395 V 90.546928 Z'
' M 58.443978,107.54692 H 183.64409 v 6.19963 H 58.443978 Z" />'
)
# 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)
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>'
)
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>',
]
# Major ticks
for x in big_xs:
parts.append(
f' <line x1="{x:.5f}" y1="{BAR_Y}" x2="{x:.5f}" y2="{BAR_Y - BIG_H:.5f}"'
f' style="stroke:#1a1a1a;stroke-width:0.555;stroke-linecap:butt" />'
)
# Minor ticks — placement depends on scale type:
# linear: uniform physical spacing (= uniform value spacing)
# log: linear value interpolation between geometric major values
# (crowds toward high end of each interval)
# sqrt: sqrt of fractional position within each interval, giving
# consistent high-end crowding across all intervals; using
# value interpolation instead would concentrate the crowding
# only near zero (where sqrt is steepest) and look linear elsewhere.
if small_ticks > 0:
sub = small_ticks + 1
for i in range(big_ticks):
for j in range(1, small_ticks + 1):
if scale_type == 'sqrt':
x = big_xs[i] + (j / sub) ** 2 * (big_xs[i + 1] - big_xs[i])
else:
v = big_vals[i] + j * (big_vals[i + 1] - big_vals[i]) / sub
x = value_to_x(v)
parts.append(
f' <line x1="{x:.5f}" y1="{BAR_Y}" x2="{x:.5f}" y2="{BAR_Y - SMALL_H:.5f}"'
f' style="stroke:#1a1a1a;stroke-width:0.302;stroke-linecap:butt" />'
)
# Numeric labels
for k, idx in enumerate(label_indices):
x = big_xs[idx]
label = custom_labels[k] if custom_labels else _fmt(big_vals[idx])
if use_paths:
parts.append(' ' + _text_to_paths(label, x, 101.49598, label_size, anchor='middle'))
else:
parts.append(
f' <text style="font-size:{label_size:.5f}px;font-family:\'ГОСТ тип А\',monospace;fill:#1a1a1a"'
f' text-anchor="middle" x="{x:.5f}" y="101.49598">{escape(label)}</text>'
)
parts += [' </g>', '</svg>']
return '\n'.join(parts)
@app.route('/')
def index():
return render_template('index.html', logo_b64=_LOGO_B64, version=VERSION)
@app.route('/generate', methods=['POST'])
def generate():
unit = request.form.get('unit', '%')
min_val = float(request.form.get('min', 0))
max_val = float(request.form.get('max', 100))
range_label = request.form.get('range_label', '5mA')
big_ticks = max(1, min(50, int(request.form.get('big_ticks', 10))))
small_ticks = max(0, min(20, int(request.form.get('small_ticks', 4))))
label_count = max(2, int(request.form.get('label_count', 6)))
use_paths = request.form.get('output_mode', 'paths') == 'paths'
scale_type = request.form.get('scale_type', 'linear')
custom_labels = [s.strip() for s in request.form.get('labels', '').split(',') if s.strip()] or None
label_size = max(0.5, min(25.0, float(request.form.get('label_size', 9))))
try:
svg = generate_svg(unit, min_val, max_val, range_label, big_ticks, small_ticks,
label_count, use_paths, scale_type, custom_labels, label_size)
except ValueError as err:
return Response(str(err), status=400, mimetype='text/plain')
return Response(svg, mimetype='image/svg+xml')
if __name__ == '__main__':
app.run(debug=True)