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:
@@ -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
@@ -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
@@ -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>',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user