@@ -79,6 +79,9 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
|
||||
# Staff, ledger, stem, beam and tie contacts are intentional. Distinct
|
||||
# noteheads, accidentals and text in the same lane must remain separate.
|
||||
checked = [p for p in system.primitives if p.role in ('head', 'tab', 'accidental', 'harmony', 'lyric', 'scale', 'section')]
|
||||
for label in (p for p in system.primitives if p.role == 'measure_number'):
|
||||
if any(label.box.overlaps(p.box, 3 * system.scale) for p in system.primitives if p is not label):
|
||||
raise generator.engraving.types.EngravingError(f'{label.sources}: measure number overlaps notation')
|
||||
for i, item in enumerate(checked):
|
||||
for other in checked[i + 1:]:
|
||||
if set(item.sources) & set(other.sources) and (item.role != 'tab' or other.role != 'tab'):
|
||||
@@ -141,3 +144,29 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
|
||||
for span in index.staff.get('octave_shifts', []):
|
||||
if not any(p.role == 'octave-shift' for p in primitives.get(span['id'], [])):
|
||||
raise generator.engraving.types.EngravingError(f'{span["id"]}: octave shift was omitted')
|
||||
|
||||
def audit_playback(pages, playback, style, fonts):
|
||||
canonical = [system for page in pages for system in page.systems]
|
||||
if len(playback) != len(canonical):
|
||||
raise generator.engraving.types.EngravingError('playback must have one view per system')
|
||||
for i, page in enumerate(playback):
|
||||
audit_primitives(page.primitives, page.width, page.height, fonts)
|
||||
expected = canonical[i:i + 2]
|
||||
if len(page.systems) != len(expected):
|
||||
raise generator.engraving.types.EngravingError('invalid playback system pairing')
|
||||
header = generator.engraving.types.bounds([p for p in page.primitives if p.role in ('title', 'subtitle')])
|
||||
footer = generator.engraving.types.bounds([p for p in page.primitives if p.role == 'page_number'])
|
||||
if page.systems[0].box.top < header.bottom + style.page_clearance - 0.01 or page.systems[-1].box.bottom > footer.top - style.page_clearance + 0.01:
|
||||
raise generator.engraving.types.EngravingError('playback overlaps header or footer')
|
||||
for a, b in zip(page.systems, page.systems[1:]):
|
||||
if a.box.bottom + style.system_gap > b.box.top + 0.01:
|
||||
raise generator.engraving.types.EngravingError('playback systems overlap')
|
||||
for source, actual in zip(expected, page.systems):
|
||||
if (source.start, source.end, source.scale, source.fragments) != (actual.start, actual.end, actual.scale, actual.fragments):
|
||||
raise generator.engraving.types.EngravingError('playback changes system content')
|
||||
dy = actual.box.top - source.box.top
|
||||
if len(source.primitives) != len(actual.primitives):
|
||||
raise generator.engraving.types.EngravingError('playback changes primitives')
|
||||
for a, b in zip(source.primitives, actual.primitives):
|
||||
if (a.kind, a.role, a.sources, a.value, a.size) != (b.kind, b.role, b.sources, b.value, b.size) or len(a.points) != len(b.points) or any(abs(x - u) > .001 or abs(y + dy - v) > .001 for (x, y), (u, v) in zip(a.points, b.points)):
|
||||
raise generator.engraving.types.EngravingError('playback changes notation geometry')
|
||||
|
||||
@@ -61,7 +61,7 @@ def restore_pages(entry: pathlib.Path, destination: pathlib.Path, validated_page
|
||||
return None
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
for index, page in enumerate(pages, 1):
|
||||
if not isinstance(page, dict) or set(page) != {'path', 'sha256', 'first_measure', 'measure_count', 'start_beat', 'end_beat'} or page['path'] != f'{index:04}.png' or not isinstance(page['sha256'], str) or not re.fullmatch('[0-9a-f]{64}', page['sha256']):
|
||||
if not isinstance(page, dict) or set(page) != {'path', 'sha256', 'first_measure', 'measure_count', 'start_beat', 'end_beat', 'systems', 'playback'} or type(page['playback']) is not bool or not isinstance(page['path'], str) or not re.fullmatch(r'(?:playback-)?[0-9]{4,5}\.png', page['path']) or not isinstance(page['sha256'], str) or not re.fullmatch('[0-9a-f]{64}', page['sha256']):
|
||||
return None
|
||||
path = entry / page['path']
|
||||
if path.is_symlink() or not path.is_file():
|
||||
@@ -88,7 +88,7 @@ def store_pages(entry: pathlib.Path, destination: pathlib.Path, pages: list[dict
|
||||
shutil.copyfile(destination / page['path'], staged / page['path'])
|
||||
(staged / 'pages.json').write_text(json.dumps(pages), encoding='utf-8')
|
||||
# Identical keys have identical pages; publish complete files, then the manifest.
|
||||
for name in [page['path'] for page in pages] + ['pages.json']:
|
||||
for name in list(dict.fromkeys(page['path'] for page in pages)) + ['pages.json']:
|
||||
target = entry / name
|
||||
if target.is_dir() and not target.is_symlink():
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
|
||||
@@ -57,7 +57,6 @@ def column(index: generator.engraving.notation.ScoreIndex, time: fractions.Fract
|
||||
items.append(fonts.text(generator.harmony.symbol(item), 0, 0, 18 if index.staff.get('tablature') else 21, 'harmony', (item['id'],)))
|
||||
items.extend(generator.engraving.scales.column(index, time, start, fonts))
|
||||
if time == measure_start:
|
||||
items.append(fonts.text(str(measure['number']), 0, -style.space * (index.state(time)['staff_details']['lines'] + 0.5), 12, 'measure_number', (measure['id'],)))
|
||||
if label := measure.get('section_label'):
|
||||
items.append(fonts.text(label, 0, 0, 16 if index.staff.get('tablature') else 19, 'section', (measure['id'] + ':section',)))
|
||||
return items, anchors, fragments
|
||||
@@ -103,6 +102,10 @@ def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions
|
||||
for owner, ci, change in index.attributes_by_onset.get(time, []):
|
||||
extra = generator.engraving.attributes.staff_attributes(index, index.state(time), time, fonts, style.space, owner['id'] + f':attributes:{ci}', tuple(change))
|
||||
leading.extend(p.moved(max((p.box.right for p in leading), default=0) + 10, 0) for p in extra)
|
||||
if time == measure_start:
|
||||
# Anchor labels to the barline, not to the first note's time column.
|
||||
bar_x = cursor - generator.engraving.types.bounds(leading).left if leading else 0
|
||||
items.append(fonts.text(str(m['number']), bar_x, 0, 12, 'measure_number', (m['id'],), True))
|
||||
if leading:
|
||||
width = generator.engraving.types.bounds(leading).right - generator.engraving.types.bounds(leading).left + 14
|
||||
items.extend(p.moved(cursor - generator.engraving.types.bounds(leading).left, 0) for p in leading)
|
||||
@@ -162,7 +165,7 @@ def fit_part_systems(systems, index, available_height, fonts, style):
|
||||
|
||||
def fitting_scale(items):
|
||||
scale = 1
|
||||
for first in range(0, len(items), 2):
|
||||
for first in range(len(items)):
|
||||
pair = items[first:first + 2]
|
||||
height = sum(s.box.bottom - s.box.top for s in pair)
|
||||
scale = min(scale, (available_height - style.system_gap * (len(pair) - 1)) / height)
|
||||
@@ -214,30 +217,36 @@ def part_header(document, fonts, style):
|
||||
result.append(fonts.text(line, style.margin_x, baseline, 12, 'subtitle', ()))
|
||||
return result
|
||||
|
||||
def paginate(systems: list[generator.engraving.types.System], document: dict, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style, *, index=None) -> tuple[generator.engraving.types.Page, ...]:
|
||||
pages = []
|
||||
def page_views(systems: list[generator.engraving.types.System], document: dict, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style, *, index=None) -> tuple[tuple[generator.engraving.types.Page, ...], tuple[generator.engraving.types.Page, ...]]:
|
||||
index = index or generator.engraving.notation.ScoreIndex(document)
|
||||
header = part_header(document, fonts, style)
|
||||
top = generator.engraving.types.bounds(header).bottom + style.page_clearance
|
||||
footers = [fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page_number', (), True) for number in range(1, (len(systems) + 1) // 2 + 1)]
|
||||
footers = [fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page_number', (), True) for number in range(1, len(systems) + 1)]
|
||||
bottom = min(p.box.top for p in footers) - style.page_clearance
|
||||
if bottom - top <= style.system_gap:
|
||||
raise generator.engraving.types.EngravingError('page header leaves no room for two music systems')
|
||||
fitted = fit_part_systems(systems, index, bottom - top, fonts, style)
|
||||
for first in range(0, len(systems), 2):
|
||||
number = len(pages) + 1
|
||||
pair = fitted[first:first + 2]
|
||||
slack = max(0, bottom - top - sum(s.box.bottom - s.box.top for s in pair) - style.system_gap * (len(pair) - 1))
|
||||
spacing = slack / 3 if len(pair) == 2 else 0
|
||||
cursor = top + spacing
|
||||
placed = []
|
||||
for system in pair:
|
||||
dx, dy = style.margin_x + 1, cursor - system.box.top
|
||||
placed.append(dataclasses.replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy)))
|
||||
cursor = placed[-1].box.bottom + style.system_gap + spacing
|
||||
footer = footers[first // 2]
|
||||
pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer])))
|
||||
return tuple(pages)
|
||||
|
||||
def place(stride):
|
||||
pages = []
|
||||
for first in range(0, len(fitted), stride):
|
||||
number = len(pages) + 1
|
||||
pair = fitted[first:first + 2]
|
||||
slack = max(0, bottom - top - sum(s.box.bottom - s.box.top for s in pair) - style.system_gap * (len(pair) - 1))
|
||||
spacing = slack / 3 if len(pair) == 2 else 0
|
||||
cursor = top + spacing
|
||||
placed = []
|
||||
for system in pair:
|
||||
dx, dy = style.margin_x + 1, cursor - system.box.top
|
||||
placed.append(dataclasses.replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy)))
|
||||
cursor = placed[-1].box.bottom + style.system_gap + spacing
|
||||
pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footers[number - 1]])))
|
||||
return tuple(pages)
|
||||
|
||||
return place(2), place(1)
|
||||
|
||||
def paginate(systems, document, fonts, style, *, index=None):
|
||||
return page_views(systems, document, fonts, style, index=index)[0]
|
||||
|
||||
def layout_systems(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts, *, index=None) -> tuple[generator.engraving.types.System, ...]:
|
||||
if index is None:
|
||||
|
||||
@@ -101,14 +101,19 @@ def clear_pages(destination):
|
||||
shutil.rmtree(destination)
|
||||
|
||||
def write_bundle(path, document, pages, fingerprint):
|
||||
entries = [{**page, 'path': path.with_suffix('.pages').name + '/' + page['path']} for page in pages]
|
||||
entries = [{k: v for k, v in {**page, 'path': path.with_suffix('.pages').name + '/' + page['path']}.items() if k != 'playback'} for page in pages]
|
||||
normal = [entry for entry, page in zip(entries, pages) if not page['playback']]
|
||||
playback = [entry for entry, page in zip(entries, pages) if page['playback']]
|
||||
music = document
|
||||
bundle = {'music': music, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'style_sha256': fingerprint,
|
||||
'width': generator.engraving.validation.PAGE_WIDTH, 'height': generator.engraving.validation.PAGE_HEIGHT, 'pages': entries}}
|
||||
generator.performance.validate_pages(document, pages)
|
||||
'width': generator.engraving.validation.PAGE_WIDTH, 'height': generator.engraving.validation.PAGE_HEIGHT, 'pages': normal, 'playback_pages': playback}}
|
||||
validate_cached_views(document, pages)
|
||||
bundle['performance'] = generator.performance.timing(document)
|
||||
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||
|
||||
def validate_cached_views(document, pages):
|
||||
generator.performance.validate_views(document, [p for p in pages if not p['playback']], [p for p in pages if p['playback']])
|
||||
|
||||
def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, progress=False):
|
||||
family = generator.engraving.fonts.Fonts(font_path)
|
||||
style = generator.engraving.types.Style()
|
||||
@@ -140,19 +145,25 @@ def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, prog
|
||||
systems = generator.engraving.layout.layout_systems(document, style, family, index=index)
|
||||
result.timings['layout'] += time.monotonic() - started
|
||||
started = time.monotonic()
|
||||
pages_layout = generator.engraving.layout.paginate(systems, document, family, style, index=index)
|
||||
pages_layout, playback_layout = generator.engraving.layout.page_views(systems, document, family, style, index=index)
|
||||
placement = tuple((page.width, page.height, tuple((system.start, system.end, system.box) for system in page.systems)) for page in pages_layout)
|
||||
if placement not in audited:
|
||||
layout = generator.engraving.types.LayoutDocument(pages_layout, style, fingerprint)
|
||||
generator.engraving.audit.audit_layout(document, layout, family)
|
||||
generator.engraving.audit.audit_playback(pages_layout, playback_layout, style, family)
|
||||
audited.add(placement)
|
||||
else:
|
||||
for page in pages_layout:
|
||||
for page in pages_layout + playback_layout:
|
||||
decoration = tuple(p for p in page.primitives if p.role in ('title', 'subtitle', 'page_number'))
|
||||
generator.engraving.audit.audit_primitives(decoration, page.width, page.height, family)
|
||||
result.timings['pagination'] += time.monotonic() - started
|
||||
pages = []
|
||||
for page in pages_layout:
|
||||
rendered = {}
|
||||
for is_playback, page in [(False, p) for p in pages_layout] + [(True, p) for p in playback_layout]:
|
||||
if page.primitives in rendered:
|
||||
name, digest = rendered[page.primitives]
|
||||
pages.append(dict(path=name, sha256=digest, playback=is_playback, **generator.performance.page_timing(document, page)))
|
||||
continue
|
||||
bands = generator.engraving.raster.page_bands(page)
|
||||
if bands is None:
|
||||
started = time.monotonic()
|
||||
@@ -161,12 +172,14 @@ def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, prog
|
||||
body = bodies.get(notation, page, bands[1], family)
|
||||
started = time.monotonic()
|
||||
image = generator.engraving.raster.compose_page(page, family, bands, body)
|
||||
target = destination / f'{page.number:04}.png'
|
||||
target = destination / (('playback-' if is_playback else '') + f'{page.number:04}.png')
|
||||
with image:
|
||||
image.save(target, format='PNG')
|
||||
result.timings['raster'] += time.monotonic() - started
|
||||
result.renderedpages += 1
|
||||
pages.append({'path': target.name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest(), **generator.performance.page_timing(document, page)})
|
||||
digest = hashlib.sha256(target.read_bytes()).hexdigest()
|
||||
rendered[page.primitives] = target.name, digest
|
||||
pages.append(dict(path=target.name, sha256=digest, playback=is_playback, **generator.performance.page_timing(document, page)))
|
||||
scores[score_key] = (destination, pages)
|
||||
write_bundle(path, document, pages, fingerprint)
|
||||
result.scores.append((path, score_key, pages))
|
||||
@@ -213,7 +226,7 @@ def engrave_tree(root: pathlib.Path, fonts: pathlib.Path, *, cache_dir: pathlib.
|
||||
pages = generator.engraving.cache.restore_pages(entry, destination, validated_pages) if entry else None
|
||||
if pages is not None:
|
||||
try:
|
||||
generator.performance.validate_pages(document, pages)
|
||||
validate_cached_views(document, pages)
|
||||
except (ValueError, TypeError, KeyError):
|
||||
pages = None
|
||||
if pages is None:
|
||||
|
||||
@@ -123,7 +123,7 @@ def draw_spans(index: generator.engraving.notation.ScoreIndex, anchors: list[gen
|
||||
y = anchor.y - anchor.direction * 8
|
||||
if anchor.x - 2 > left:
|
||||
result.append(generator.engraving.types.curve('tie', (identifier,), ((left, y), (left + 5, y - anchor.direction * 9), (anchor.x - 7, y - anchor.direction * 9), (anchor.x - 2, y)), 1.6))
|
||||
top = min(-style.space * 5, min((p.box.top for p in items if p.role not in ('harmony', 'lyric', 'section', 'scale', 'tempo')), default=0))
|
||||
top = min(-style.space * 5, min((p.box.top for p in items if p.role not in ('harmony', 'lyric', 'section', 'scale', 'tempo', 'measure_number')), default=0))
|
||||
tuplet_lanes = []
|
||||
for span in index.staff.get('tuplets', []):
|
||||
a, b = index.by_id[span['start']], index.by_id[span['end']]
|
||||
@@ -203,11 +203,11 @@ def draw_spans(index: generator.engraving.notation.ScoreIndex, anchors: list[gen
|
||||
return items + result
|
||||
|
||||
def place_text_lanes(items: list[generator.engraving.types.Primitive], fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]:
|
||||
music = [p for p in items if p.role not in ('harmony', 'lyric', 'section', 'scale')]
|
||||
music = [p for p in items if p.role not in ('harmony', 'lyric', 'section', 'scale', 'measure_number')]
|
||||
box = generator.engraving.types.bounds(music)
|
||||
result = list(music)
|
||||
top = box.top - 12
|
||||
for role in ('harmony', 'scale', 'section'):
|
||||
for role in ('measure_number', 'harmony', 'scale', 'section'):
|
||||
lane = [p for p in items if p.role == role]
|
||||
if lane:
|
||||
extent = generator.engraving.types.bounds(lane)
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import fractions
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
@@ -23,7 +24,63 @@ def page_timing(document, page):
|
||||
times = boundaries(document)
|
||||
start, end = page.systems[0].start, page.systems[-1].end
|
||||
first, last = times.index(start), times.index(end)
|
||||
return dict(first_measure=first + 1, measure_count=last - first, start_beat=generator.model.rational(start), end_beat=generator.model.rational(end))
|
||||
measures = document['staves'][0]['measures']
|
||||
systems = []
|
||||
for system in page.systems:
|
||||
a, b = times.index(system.start), times.index(system.end)
|
||||
labels = {p.sources[0]: p for p in system.primitives if p.role == 'measure_number'}
|
||||
boxes = []
|
||||
for i in range(a, b):
|
||||
box = labels[measures[i]['id']].box
|
||||
boxes.append(dict(measure=i + 1, bounds=[box.left, box.top, box.right, box.bottom]))
|
||||
systems.append(dict(first_measure=a + 1, measure_count=b - a, start_beat=generator.model.rational(system.start), end_beat=generator.model.rational(system.end), labels=boxes))
|
||||
return dict(first_measure=first + 1, measure_count=last - first, start_beat=generator.model.rational(start), end_beat=generator.model.rational(end), systems=systems)
|
||||
|
||||
def validate_views(document, pages, playback_pages):
|
||||
validate_pages(document, pages)
|
||||
times = boundaries(document)
|
||||
canonical = []
|
||||
|
||||
def system_range(system):
|
||||
a, count = system['first_measure'], system['measure_count']
|
||||
if type(a) is not int or type(count) is not int or a < 1 or count < 1 or a + count > len(times):
|
||||
raise ValueError('invalid system measure coverage')
|
||||
if generator.model.fraction(system['start_beat']) != times[a - 1] or generator.model.fraction(system['end_beat']) != times[a + count - 1]:
|
||||
raise ValueError('invalid system timing')
|
||||
if [label['measure'] for label in system['labels']] != list(range(a, a + count)):
|
||||
raise ValueError('invalid measure label coverage')
|
||||
for label in system['labels']:
|
||||
box = label['bounds']
|
||||
if not isinstance(box, list) or len(box) != 4 or any(type(v) not in (int, float) or not math.isfinite(v) for v in box) or not (4 <= box[0] < box[2] <= 972 and 4 <= box[1] < box[3] <= 480):
|
||||
raise ValueError('invalid measure label bounds')
|
||||
return a, count
|
||||
|
||||
for page in pages:
|
||||
systems = page.get('systems', [])
|
||||
if len(systems) != 2 and (page is not pages[-1] or len(systems) != 1):
|
||||
raise ValueError('invalid page systems')
|
||||
cursor = page['first_measure']
|
||||
for system in systems:
|
||||
a, count = system_range(system)
|
||||
if a != cursor:
|
||||
raise ValueError('system gap or overlap')
|
||||
cursor += count
|
||||
canonical.append(system)
|
||||
if cursor != page['first_measure'] + page['measure_count']:
|
||||
raise ValueError('systems disagree with page coverage')
|
||||
if not isinstance(playback_pages, list) or len(playback_pages) != len(canonical):
|
||||
raise ValueError('missing playback pages; regenerate score assets')
|
||||
for i, page in enumerate(playback_pages):
|
||||
expected = canonical[i:i + 2]
|
||||
systems = page.get('systems', [])
|
||||
if len(systems) != len(expected):
|
||||
raise ValueError('invalid playback systems')
|
||||
for actual, source in zip(systems, expected):
|
||||
system_range(actual)
|
||||
if any(actual[k] != source[k] for k in ('first_measure', 'measure_count', 'start_beat', 'end_beat')):
|
||||
raise ValueError('playback changes system boundaries')
|
||||
if any(page[k] != expected[0][k] for k in ('first_measure', 'start_beat')) or page['end_beat'] != expected[-1]['end_beat'] or page['measure_count'] != sum(s['measure_count'] for s in expected):
|
||||
raise ValueError('invalid playback page timing')
|
||||
|
||||
def validate_pages(document, pages):
|
||||
times = boundaries(document)
|
||||
|
||||
+2
-2
@@ -24,10 +24,10 @@ def validate_json(document: dict, path: pathlib.Path, validated_pages: set[str]
|
||||
raise ValueError('invalid score bundle; regenerate score assets')
|
||||
if (presentation.get('engine'), presentation.get('width'), presentation.get('height')) != (generator.engraving.validation.ENGINE_NAME, generator.engraving.validation.PAGE_WIDTH, generator.engraving.validation.PAGE_HEIGHT):
|
||||
raise ValueError('unsupported score presentation; regenerate score assets')
|
||||
generator.performance.validate_pages(document['music'], presentation['pages'])
|
||||
generator.performance.validate_views(document['music'], presentation['pages'], presentation.get('playback_pages'))
|
||||
if document.get('performance') != generator.performance.timing(document['music']):
|
||||
raise ValueError('invalid performance timing')
|
||||
for page in presentation['pages']:
|
||||
for page in presentation['pages'] + presentation['playback_pages']:
|
||||
if not isinstance(page, dict) or not isinstance(page.get('path'), str) or not isinstance(page.get('sha256'), str):
|
||||
raise ValueError('invalid score page manifest')
|
||||
name = page['path']
|
||||
|
||||
Reference in New Issue
Block a user