f676651edb
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.
492 lines
18 KiB
Python
492 lines
18 KiB
Python
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.77"
|
||
|
||
_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, 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" />'
|
||
)
|
||
|
||
# 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 — 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)
|