Files
44-Scale/web/app.py
T
adebaumann 54a7f072c4 Add configurable label font size; bump to 0.76
- New "Label size (mm)" field controlling the numeric-label font size,
  defaulting to a clean 9 mm (was the inherited Inkscape value 8.89194)
- Pin k8s deployment to image tag 0.76
2026-07-13 00:59:14 +02:00

241 lines
8.6 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.76"
_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
# 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_CHAR_EM = 0.65
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))
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, log_scale=False, custom_labels=None, label_size=9):
if log_scale:
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)
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 + (max_val - min_val) * 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" />'
)
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),
]
else:
fs = _unit_font_size(unit)
parts += [
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' <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 — evenly spaced in value, then positioned by the scale mapping.
# On a linear scale this is uniform; on a log scale they crowd toward the high end.
if small_ticks > 0:
sub = small_ticks + 1
for i in range(big_ticks):
for j in range(1, small_ticks + 1):
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'
log_scale = request.form.get('scale_type', 'linear') == 'log'
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, log_scale, 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)