Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3b8fea2a5 | |||
| 83a84b7941 | |||
| 19e915f4e1 | |||
| ef39ce4046 | |||
| 5cfc4ea36f | |||
| efc9c37522 |
21
.github/workflows/build.yml
vendored
21
.github/workflows/build.yml
vendored
@@ -2,7 +2,7 @@ name: Build fonts
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, develop]
|
||||
tags: ["*"]
|
||||
|
||||
jobs:
|
||||
@@ -12,28 +12,19 @@ jobs:
|
||||
image: ghcr.io/nicoverbruggen/fntbld-oci:latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Build fonts
|
||||
run: python3 build.py
|
||||
|
||||
- name: Download kobofix.py
|
||||
run: curl -sL https://raw.githubusercontent.com/nicoverbruggen/kobo-font-fix/main/kobofix.py -o kobofix.py
|
||||
|
||||
- name: Generate Kobo (KF) fonts
|
||||
run: |
|
||||
python3 kobofix.py --preset kf out/ttf/*.ttf
|
||||
mkdir -p out/kf
|
||||
mv out/ttf/KF_*.ttf out/kf/
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Readerly
|
||||
path: out/ttf/*.ttf
|
||||
|
||||
- name: Upload Kobo artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: KF_Readerly
|
||||
path: out/kf/*.ttf
|
||||
@@ -46,7 +37,7 @@ jobs:
|
||||
|
||||
- name: Upload release zips
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Readerly-release
|
||||
path: |
|
||||
@@ -61,7 +52,7 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: Readerly-release
|
||||
|
||||
|
||||
365
build.py
365
build.py
@@ -8,7 +8,7 @@ Orchestrates the full font build pipeline:
|
||||
2. Applies vertical scale (scale.py) via FontForge
|
||||
3. Applies vertical metrics, line height, rename (metrics.py, lineheight.py, rename.py)
|
||||
4. Exports to TTF → ./out/ttf/
|
||||
5. Post-processes TTFs: x-height overshoot clamping, style flags, autohinting
|
||||
5. Post-processes TTFs: style flags, kern pairs, autohinting
|
||||
|
||||
Uses FontForge (detected automatically).
|
||||
Run with: python3 build.py
|
||||
@@ -36,12 +36,14 @@ import textwrap
|
||||
# - LINE_HEIGHT: Typo line height (default line spacing)
|
||||
# - SELECTION_HEIGHT: Win/hhea selection box height and clipping
|
||||
# - ASCENDER_RATIO: ascender share of total height
|
||||
# - KERN_PAIRS: explicit GPOS kern pairs (for devices without ligatures)
|
||||
# - STYLE_MAP: naming/weight metadata per style
|
||||
|
||||
ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
SRC_DIR = os.path.join(ROOT_DIR, "src")
|
||||
OUT_DIR = os.path.join(ROOT_DIR, "out")
|
||||
OUT_TTF_DIR = os.path.join(OUT_DIR, "ttf") # generated TTFs
|
||||
OUT_KF_DIR = os.path.join(OUT_DIR, "kf") # Kobo (KF) variants
|
||||
|
||||
REGULAR_VF = os.path.join(SRC_DIR, "Newsreader-VariableFont_opsz,wght.ttf")
|
||||
ITALIC_VF = os.path.join(SRC_DIR, "Newsreader-Italic-VariableFont_opsz,wght.ttf")
|
||||
@@ -89,22 +91,38 @@ ASCENDER_RATIO = 0.8
|
||||
|
||||
# Step 4: ttfautohint options (hinting for Kobo's FreeType renderer)
|
||||
# - Kobo uses FreeType grayscale, so the 1st char of --stem-width-mode
|
||||
# (gray) is the one that matters. n=natural, q=quantized, s=strong.
|
||||
# - Remaining two chars are for GDI and DirectWrite (not used on Kobo).
|
||||
# - Other options are left at ttfautohint defaults; uncomment to override.
|
||||
# matters: n=natural (least distortion), q=quantized, s=strong.
|
||||
# - x-height snapping is disabled to avoid inconsistent glyph heights.
|
||||
AUTOHINT_OPTS = [
|
||||
"--no-info",
|
||||
"--stem-width-mode=nss",
|
||||
# "--hinting-range-min=8",
|
||||
# "--hinting-range-max=50",
|
||||
# "--hinting-limit=200",
|
||||
"--increase-x-height=0",
|
||||
'--x-height-snapping-exceptions=-',
|
||||
]
|
||||
|
||||
# Glyphs whose x-height overshoot is an outlier (+12 vs the standard +22).
|
||||
# The inconsistent overshoot lands between the hinter's snap zones, causing
|
||||
# these glyphs to render taller than their neighbors on low-res e-ink.
|
||||
CLAMP_XHEIGHT_GLYPHS = ["u", "uogonek"]
|
||||
# Baseline alignment: deepen the bottom anti-aliasing of non-serifed
|
||||
# glyphs via hinting-only touch deltas (no outline changes). This
|
||||
# shifts their bottom points down during rasterization so they produce
|
||||
# more gray below the baseline, visually matching serifed characters.
|
||||
# Each entry is (shift_px, ppem_min, ppem_max). Shifts are in pixels
|
||||
# (multiples of 1/8, max 1.0). Set to empty list to disable.
|
||||
BASELINE_HINT_SHIFTS = [
|
||||
(0.125, 6, 53),
|
||||
]
|
||||
|
||||
# Per-glyph Y ceiling: cap the top of specific glyphs to reduce
|
||||
# oversized or awkward serifs. (glyph_name, max_y)
|
||||
# Points above max_y are clamped down to max_y.
|
||||
GLYPH_Y_CEILING = [
|
||||
("u", 1062), # flatten tiny top serif tips to platform level
|
||||
]
|
||||
|
||||
# Explicit kern pairs: (left_glyph, right_glyph, kern_value_in_units).
|
||||
# Negative values tighten spacing. These are added on top of any existing
|
||||
# kerning from the source variable font.
|
||||
KERN_PAIRS = [
|
||||
("f", "i", -100), # this emulates the `fi` ligature
|
||||
]
|
||||
|
||||
# Step 3: Naming and style metadata (used by the rename step)
|
||||
STYLE_MAP = {
|
||||
@@ -602,61 +620,6 @@ def clean_ttf_degenerate_contours(ttf_path):
|
||||
font.close()
|
||||
|
||||
|
||||
def clamp_xheight_overshoot(ttf_path):
|
||||
"""Clamp outlier x-height overshoots in a TTF in-place.
|
||||
|
||||
Some glyphs (e.g. 'u') have a smaller overshoot than the standard
|
||||
round overshoot, landing between the hinter's snap zones. This
|
||||
flattens them to the true x-height measured from flat-topped glyphs.
|
||||
"""
|
||||
try:
|
||||
from fontTools.ttLib import TTFont
|
||||
except Exception:
|
||||
print(" [warn] Skipping x-height clamp: fontTools not available", file=sys.stderr)
|
||||
return
|
||||
|
||||
font = TTFont(ttf_path)
|
||||
glyf = font["glyf"]
|
||||
|
||||
# Measure x-height from flat-topped reference glyphs.
|
||||
xheight = 0
|
||||
for ref in ("x", "v"):
|
||||
if ref not in glyf:
|
||||
continue
|
||||
coords = glyf[ref].coordinates
|
||||
if coords:
|
||||
ymax = max(c[1] for c in coords)
|
||||
if ymax > xheight:
|
||||
xheight = ymax
|
||||
|
||||
if xheight == 0:
|
||||
font.close()
|
||||
return
|
||||
|
||||
clamped = []
|
||||
for name in CLAMP_XHEIGHT_GLYPHS:
|
||||
if name not in glyf:
|
||||
continue
|
||||
glyph = glyf[name]
|
||||
coords = glyph.coordinates
|
||||
if not coords:
|
||||
continue
|
||||
ymax = max(c[1] for c in coords)
|
||||
if ymax <= xheight:
|
||||
continue
|
||||
glyph.coordinates = type(coords)(
|
||||
[(x, min(y, xheight)) for x, y in coords]
|
||||
)
|
||||
glyph_set = font.getGlyphSet()
|
||||
if hasattr(glyph, "recalcBounds"):
|
||||
glyph.recalcBounds(glyph_set)
|
||||
clamped.append(name)
|
||||
|
||||
if clamped:
|
||||
font.save(ttf_path)
|
||||
print(f" Clamped x-height overshoot for: {', '.join(clamped)} (xh={xheight})")
|
||||
font.close()
|
||||
|
||||
|
||||
def fix_ttf_style_flags(ttf_path, style_suffix):
|
||||
"""Normalize OS/2 fsSelection and head.macStyle for style linking."""
|
||||
@@ -692,6 +655,190 @@ def fix_ttf_style_flags(ttf_path, style_suffix):
|
||||
print(f" Normalized style flags for {style_suffix}")
|
||||
|
||||
|
||||
def add_kern_pairs(ttf_path):
|
||||
"""Prepend explicit kern pairs to the GPOS kern table.
|
||||
|
||||
Devices that don't support OpenType ligatures (e.g. some e-readers)
|
||||
fall back to individual glyphs. Without kern pairs, combinations
|
||||
like 'fi' render with a visible gap.
|
||||
|
||||
Pairs are inserted at the front of the first PairPos subtable so
|
||||
they take priority even on renderers that truncate the kern list.
|
||||
"""
|
||||
if not KERN_PAIRS:
|
||||
return
|
||||
|
||||
try:
|
||||
from fontTools.ttLib import TTFont
|
||||
from fontTools.ttLib.tables.otTables import PairValueRecord, ValueRecord
|
||||
except Exception:
|
||||
print(" [warn] Skipping kern pairs: fontTools not available", file=sys.stderr)
|
||||
return
|
||||
|
||||
font = TTFont(ttf_path)
|
||||
gpos = font.get("GPOS")
|
||||
if gpos is None:
|
||||
font.close()
|
||||
print(" [warn] No GPOS table, skipping kern pairs", file=sys.stderr)
|
||||
return
|
||||
|
||||
cmap = font.getBestCmap()
|
||||
# Map glyph names: try cmap first, fall back to glyph order
|
||||
glyph_order = set(font.getGlyphOrder())
|
||||
|
||||
def resolve(name):
|
||||
# If it's a single character, look up via cmap
|
||||
if len(name) == 1:
|
||||
cp = ord(name)
|
||||
if cp in cmap:
|
||||
return cmap[cp]
|
||||
# Otherwise treat as a glyph name
|
||||
if name in glyph_order:
|
||||
return name
|
||||
return None
|
||||
|
||||
# Build resolved pairs
|
||||
pairs = []
|
||||
for left, right, value in KERN_PAIRS:
|
||||
l = resolve(left)
|
||||
r = resolve(right)
|
||||
if l and r:
|
||||
pairs.append((l, r, value))
|
||||
else:
|
||||
print(f" [warn] Kern pair {left}+{right}: glyph not found", file=sys.stderr)
|
||||
|
||||
if not pairs:
|
||||
font.close()
|
||||
return
|
||||
|
||||
# Find the first Format 1 (individual pairs) PairPos subtable in kern
|
||||
pair_pos = None
|
||||
for lookup in gpos.table.LookupList.Lookup:
|
||||
if lookup.LookupType == 2: # PairPos
|
||||
for subtable in lookup.SubTable:
|
||||
if subtable.Format == 1:
|
||||
pair_pos = subtable
|
||||
break
|
||||
if pair_pos:
|
||||
break
|
||||
|
||||
if pair_pos is None:
|
||||
font.close()
|
||||
print(" [warn] No Format 1 PairPos subtable found, skipping kern pairs", file=sys.stderr)
|
||||
return
|
||||
|
||||
count = 0
|
||||
for left_glyph, right_glyph, value in pairs:
|
||||
# Find or create the PairSet for the left glyph
|
||||
try:
|
||||
idx = pair_pos.Coverage.glyphs.index(left_glyph)
|
||||
except ValueError:
|
||||
# Left glyph not in coverage — add it
|
||||
pair_pos.Coverage.glyphs.append(left_glyph)
|
||||
from fontTools.ttLib.tables.otTables import PairSet
|
||||
ps = PairSet()
|
||||
ps.PairValueRecord = []
|
||||
ps.PairValueCount = 0
|
||||
pair_pos.PairSet.append(ps)
|
||||
pair_pos.PairSetCount = len(pair_pos.PairSet)
|
||||
idx = len(pair_pos.Coverage.glyphs) - 1
|
||||
|
||||
pair_set = pair_pos.PairSet[idx]
|
||||
|
||||
# Remove existing pair for same right glyph
|
||||
pair_set.PairValueRecord = [
|
||||
pvr for pvr in pair_set.PairValueRecord
|
||||
if pvr.SecondGlyph != right_glyph
|
||||
]
|
||||
|
||||
# Prepend new pair so it appears first
|
||||
pvr = PairValueRecord()
|
||||
pvr.SecondGlyph = right_glyph
|
||||
vr = ValueRecord()
|
||||
vr.XAdvance = value
|
||||
pvr.Value1 = vr
|
||||
pair_set.PairValueRecord.insert(0, pvr)
|
||||
pair_set.PairValueCount = len(pair_set.PairValueRecord)
|
||||
count += 1
|
||||
|
||||
font.save(ttf_path)
|
||||
font.close()
|
||||
print(f" Added {count} kern pair(s) to GPOS")
|
||||
|
||||
|
||||
|
||||
def apply_glyph_y_ceiling(ttf_path):
|
||||
"""Clamp glyph points above a Y ceiling down to the ceiling value."""
|
||||
if not GLYPH_Y_CEILING:
|
||||
return
|
||||
|
||||
from fontTools.ttLib import TTFont
|
||||
font = TTFont(ttf_path)
|
||||
glyf = font["glyf"]
|
||||
modified = []
|
||||
|
||||
for glyph_name, max_y in GLYPH_Y_CEILING:
|
||||
g = glyf.get(glyph_name)
|
||||
if not g or not g.numberOfContours or g.numberOfContours <= 0:
|
||||
continue
|
||||
coords = g.coordinates
|
||||
clamped = 0
|
||||
for j in range(len(coords)):
|
||||
if coords[j][1] > max_y:
|
||||
coords[j] = (coords[j][0], max_y)
|
||||
clamped += 1
|
||||
if clamped:
|
||||
modified.append(f"{glyph_name}({clamped}pts)")
|
||||
|
||||
if modified:
|
||||
font.save(ttf_path)
|
||||
print(f" Clamped Y ceiling: {', '.join(modified)}")
|
||||
font.close()
|
||||
|
||||
|
||||
def _generate_baseline_shift_ctrl(ttf_path):
|
||||
"""Generate touch deltas to deepen bottom anti-aliasing of non-serifed glyphs.
|
||||
|
||||
For lowercase glyphs without a flat baseline (no serif foot), shifts
|
||||
the bottom-most points down during rasterization. Uses graduated
|
||||
shifts from BASELINE_HINT_SHIFTS — stronger at small ppem sizes
|
||||
where alignment is most noticeable. No outline changes.
|
||||
"""
|
||||
if not BASELINE_HINT_SHIFTS:
|
||||
return ""
|
||||
|
||||
from fontTools.ttLib import TTFont
|
||||
font = TTFont(ttf_path)
|
||||
glyf = font["glyf"]
|
||||
cmap = font.getBestCmap()
|
||||
lines = []
|
||||
|
||||
for char in "abcdefghijklmnopqrstuvwxyz":
|
||||
code = ord(char)
|
||||
if code not in cmap:
|
||||
continue
|
||||
name = cmap[code]
|
||||
g = glyf[name]
|
||||
if not g.numberOfContours or g.numberOfContours <= 0:
|
||||
continue
|
||||
coords = g.coordinates
|
||||
ys = set(c[1] for c in coords)
|
||||
if 0 in ys:
|
||||
continue # has serif baseline
|
||||
bottom_pts = [i for i, (x, y) in enumerate(coords) if y <= 0]
|
||||
if not bottom_pts:
|
||||
continue
|
||||
pts_str = ", ".join(str(p) for p in bottom_pts)
|
||||
for shift_px, ppem_min, ppem_max in BASELINE_HINT_SHIFTS:
|
||||
shift = -abs(shift_px)
|
||||
lines.append(
|
||||
f"{name} touch {pts_str} yshift {shift:.3f} @ {ppem_min}-{ppem_max}"
|
||||
)
|
||||
|
||||
font.close()
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def autohint_ttf(ttf_path):
|
||||
"""Run ttfautohint to add proper TrueType hinting.
|
||||
|
||||
@@ -706,16 +853,34 @@ def autohint_ttf(ttf_path):
|
||||
hinting, which may handle sub-baseline overshoots more gracefully.
|
||||
The resulting bytecode is baked into the font, so FreeType uses
|
||||
the TrueType interpreter instead of falling back to auto-hinting.
|
||||
|
||||
Additionally generates per-font touch deltas to deepen the
|
||||
baseline anti-aliasing of non-serifed glyphs.
|
||||
"""
|
||||
if not shutil.which("ttfautohint"):
|
||||
print(" [warn] ttfautohint not found, skipping", file=sys.stderr)
|
||||
return
|
||||
|
||||
# Generate control instructions for this specific font's points
|
||||
ctrl_text = _generate_baseline_shift_ctrl(ttf_path)
|
||||
ctrl_path = ttf_path + ".ctrl.tmp"
|
||||
ctrl_count = 0
|
||||
opts = list(AUTOHINT_OPTS)
|
||||
if ctrl_text:
|
||||
with open(ctrl_path, "w") as f:
|
||||
f.write(ctrl_text)
|
||||
opts += [f"--control-file={ctrl_path}"]
|
||||
ctrl_count = ctrl_text.count("\n") + 1
|
||||
|
||||
tmp_path = ttf_path + ".autohint.tmp"
|
||||
result = subprocess.run(
|
||||
["ttfautohint"] + AUTOHINT_OPTS + [ttf_path, tmp_path],
|
||||
["ttfautohint"] + opts + [ttf_path, tmp_path],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
|
||||
if os.path.exists(ctrl_path):
|
||||
os.remove(ctrl_path)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f" [warn] ttfautohint failed: {result.stderr.strip()}", file=sys.stderr)
|
||||
if os.path.exists(tmp_path):
|
||||
@@ -723,7 +888,10 @@ def autohint_ttf(ttf_path):
|
||||
return
|
||||
|
||||
os.replace(tmp_path, ttf_path)
|
||||
print(f" Autohinted with ttfautohint")
|
||||
hint_msg = "Autohinted with ttfautohint"
|
||||
if ctrl_count:
|
||||
hint_msg += f" ({ctrl_count} serif control hints)"
|
||||
print(f" {hint_msg}")
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
@@ -748,6 +916,45 @@ def check_ttfautohint():
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
KOBOFIX_URL = (
|
||||
"https://raw.githubusercontent.com/nicoverbruggen/kobo-font-fix/main/kobofix.py"
|
||||
)
|
||||
|
||||
|
||||
def _download_kobofix(dest):
|
||||
"""Download kobofix.py if not already cached."""
|
||||
if os.path.isfile(dest):
|
||||
print(f" Using cached kobofix.py")
|
||||
return
|
||||
import urllib.request
|
||||
print(f" Downloading kobofix.py ...")
|
||||
urllib.request.urlretrieve(KOBOFIX_URL, dest)
|
||||
print(f" Saved to {dest}")
|
||||
|
||||
|
||||
def _run_kobofix(kobofix_path, variant_names):
|
||||
"""Run kobofix.py --preset kf on built TTFs, move KF_ files to out/kf/."""
|
||||
ttf_files = [os.path.join(OUT_TTF_DIR, f"{n}.ttf") for n in variant_names]
|
||||
cmd = [sys.executable, kobofix_path, "--preset", "kf"] + ttf_files
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.stdout:
|
||||
print(result.stdout, end="")
|
||||
if result.returncode != 0:
|
||||
print("\nERROR: kobofix.py failed", file=sys.stderr)
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
os.makedirs(OUT_KF_DIR, exist_ok=True)
|
||||
import glob
|
||||
moved = 0
|
||||
for kf_file in glob.glob(os.path.join(OUT_TTF_DIR, "KF_*.ttf")):
|
||||
dest = os.path.join(OUT_KF_DIR, os.path.basename(kf_file))
|
||||
shutil.move(kf_file, dest)
|
||||
moved += 1
|
||||
print(f" Moved {moved} KF font(s) to {OUT_KF_DIR}/")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print(" Readerly Build")
|
||||
@@ -761,9 +968,18 @@ def main():
|
||||
family = DEFAULT_FAMILY
|
||||
outline_fix = True
|
||||
|
||||
# --name "Foo" sets the family name directly
|
||||
if "--name" in sys.argv:
|
||||
idx = sys.argv.index("--name")
|
||||
if idx + 1 < len(sys.argv):
|
||||
family = sys.argv[idx + 1]
|
||||
else:
|
||||
print("ERROR: --name requires a value", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if "--customize" in sys.argv:
|
||||
print()
|
||||
family = input(f" Font family name [{DEFAULT_FAMILY}]: ").strip() or DEFAULT_FAMILY
|
||||
family = input(f" Font family name [{family}]: ").strip() or family
|
||||
outline_input = input(" Apply outline fixes (remove overlaps + zero-area cleanup)? [Y/n]: ").strip().lower()
|
||||
outline_fix = outline_input not in ("n", "no")
|
||||
|
||||
@@ -879,14 +1095,23 @@ def _build(tmp_dir, family=DEFAULT_FAMILY, outline_fix=True):
|
||||
run_fontforge_script(script)
|
||||
if outline_fix:
|
||||
clean_ttf_degenerate_contours(ttf_path)
|
||||
clamp_xheight_overshoot(ttf_path)
|
||||
fix_ttf_style_flags(ttf_path, style_suffix)
|
||||
add_kern_pairs(ttf_path)
|
||||
apply_glyph_y_ceiling(ttf_path)
|
||||
autohint_ttf(ttf_path)
|
||||
|
||||
|
||||
# Step 5: Generate Kobo (KF) variants via kobofix.py
|
||||
print("\n── Step 5: Generate Kobo (KF) variants ──\n")
|
||||
|
||||
kobofix_path = os.path.join(tmp_dir, "kobofix.py")
|
||||
_download_kobofix(kobofix_path)
|
||||
_run_kobofix(kobofix_path, variant_names)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" Build complete!")
|
||||
print(f" TTF fonts are in: {OUT_TTF_DIR}/")
|
||||
print(f" KF fonts are in: {OUT_KF_DIR}/")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
|
||||
BIN
screenshot.png
Executable file → Normal file
BIN
screenshot.png
Executable file → Normal file
Binary file not shown.
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 192 KiB |
Reference in New Issue
Block a user