docs: README (Italian) and MIT licence with font carve-out
This commit is contained in:
@@ -0,0 +1,482 @@
|
||||
// lib.typ — shared kernel imported by every template in templates/.
|
||||
//
|
||||
// Templates are fixed and reviewed; the LLM never writes Typst. Everything here reads
|
||||
// the ResolvedSpec (see extensions/imgen/design/spec.ts) as DATA, loaded once by the
|
||||
// template with:
|
||||
//
|
||||
// #let spec = json(sys.inputs.specfile)
|
||||
//
|
||||
// Two invariants this file must never break:
|
||||
// 1. Determinism. No randomness, no dates, no system fonts, no wall-clock. Combined
|
||||
// with --ignore-system-fonts + SOURCE_DATE_EPOCH + `#set document(date: none)`
|
||||
// the output is byte-identical across runs, which is what makes the golden-file
|
||||
// tests meaningful.
|
||||
// 2. Resolution independence. Sizes are expressed as fractions of the trim, so the
|
||||
// same template is correct at 1080 px and at 300 dpi A3.
|
||||
//
|
||||
// Colour note: this is an RGB pipeline on purpose. Never introduce cmyk() — its
|
||||
// gradients render wrong (typst#4422) and CMYK ICC images produce PDFs Acrobat
|
||||
// refuses to open (typst#3781).
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers — tolerant readers for a spec whose optional fields may be
|
||||
// missing entirely, or present but JSON `null` (which reaches Typst as `none`).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read `key` from dictionary `d`, falling back to `default` when the dictionary is
|
||||
/// not a dictionary, the key is absent, or the value is `none`.
|
||||
#let _get(d, key, default) = {
|
||||
if type(d) != dictionary { return default }
|
||||
let v = d.at(key, default: default)
|
||||
if v == none { default } else { v }
|
||||
}
|
||||
|
||||
/// Read a nested path, e.g. `_dig(spec, ("page", "bleedMm"), 0)`. Same tolerance as
|
||||
/// `_get` at every level, so a missing `page` object degrades instead of panicking.
|
||||
#let _dig(d, path, default) = {
|
||||
let cur = d
|
||||
for key in path {
|
||||
if type(cur) != dictionary { return default }
|
||||
cur = cur.at(key, default: none)
|
||||
if cur == none { return default }
|
||||
}
|
||||
cur
|
||||
}
|
||||
|
||||
/// Parse a spec hex string into a colour. Anything unparseable falls back rather than
|
||||
/// killing the render — a wrong colour is recoverable, a failed poster is not.
|
||||
#let hex(v, fallback: black) = {
|
||||
if type(v) == color { return v }
|
||||
if type(v) != str { return fallback }
|
||||
let s = v.trim()
|
||||
if not s.starts-with("#") { return fallback }
|
||||
if s.len() in (4, 7, 9) { rgb(s) } else { fallback }
|
||||
}
|
||||
|
||||
/// The four spec colours, always complete. `ink_resolved` is the contrast-checked ink
|
||||
/// the renderer computed; it wins over the art director's `palette.ink`.
|
||||
#let palette-of(spec) = {
|
||||
let p = _get(spec, "palette", (:))
|
||||
let ink = hex(_get(spec, "ink_resolved", _get(p, "ink", none)), fallback: rgb("#111111"))
|
||||
(
|
||||
bg: hex(_get(p, "bg", none), fallback: rgb("#ffffff")),
|
||||
ink: ink,
|
||||
accent: hex(_get(p, "accent", none), fallback: ink),
|
||||
scrim: hex(_get(p, "scrim", none), fallback: rgb("#000000")),
|
||||
)
|
||||
}
|
||||
|
||||
/// Font family for a role group: "display" for headlines, "body" for everything else.
|
||||
/// Returns `none` when the spec omits it, which callers pass straight through (Typst
|
||||
/// then keeps the inherited family instead of erroring on `font: none`).
|
||||
#let font-of(spec, kind) = {
|
||||
let v = _dig(spec, ("fonts", kind), none)
|
||||
if type(v) == str and v.trim() != "" { v } else { none }
|
||||
}
|
||||
|
||||
/// Variable-font axes usable for `family`, as `(wdth: (62, 125), wght: (100, 900))`.
|
||||
/// Optional: the renderer may inject `spec.font_axes` (family -> axis ranges) from
|
||||
/// design/fonts.ts. Absent, this returns `(:)` and `fit` simply bisects on size.
|
||||
#let font-axes(spec, family) = {
|
||||
if type(family) != str { return (:) }
|
||||
let table = _get(spec, "font_axes", (:))
|
||||
if type(table) != dictionary { return (:) }
|
||||
let a = table.at(family, default: (:))
|
||||
if type(a) == dictionary { a } else { (:) }
|
||||
}
|
||||
|
||||
/// Trim size in mm, as plain numbers. Bleed deliberately excluded: every ratio in this
|
||||
/// file is a fraction of the TRIM, so adding bleed never changes how big the title is.
|
||||
#let trim-mm(spec) = (
|
||||
width: _dig(spec, ("page", "widthMm"), 210),
|
||||
height: _dig(spec, ("page", "heightMm"), 297),
|
||||
)
|
||||
|
||||
/// Short edge of the trim, in mm. The scale reference for every type ratio and for the
|
||||
/// logo, so a portrait and a landscape poster get proportionally the same headline.
|
||||
#let short-edge-mm(spec) = {
|
||||
let t = trim-mm(spec)
|
||||
calc.min(t.width, t.height)
|
||||
}
|
||||
|
||||
/// Normalise an axis range from the spec: `(62, 125)`, `(min: 62, max: 125)` or a bare
|
||||
/// number all become a `(lo, hi)` pair; anything else becomes `none`.
|
||||
#let _axis-range(a) = {
|
||||
if a == none { return none }
|
||||
if type(a) == array and a.len() >= 2 { return (a.at(0), a.at(1)) }
|
||||
if type(a) == dictionary {
|
||||
let lo = a.at("min", default: a.at("lo", default: none))
|
||||
let hi = a.at("max", default: a.at("hi", default: none))
|
||||
if lo != none and hi != none { return (lo, hi) }
|
||||
}
|
||||
if type(a) in (int, float) { return (a, a) }
|
||||
none
|
||||
}
|
||||
|
||||
/// Build the `text()` arguments dictionary, skipping every key the caller left `none`
|
||||
/// so we never hand Typst `font: none` or an empty `variations`.
|
||||
#let _text-args(family, size, weight, tracking, fill, wdth) = {
|
||||
let args = (size: size)
|
||||
if family != none { args.insert("font", family) }
|
||||
if weight != none { args.insert("weight", weight) }
|
||||
if tracking != none { args.insert("tracking", tracking) }
|
||||
if fill != none { args.insert("fill", fill) }
|
||||
if wdth != none { args.insert("variations", (wdth: wdth)) }
|
||||
args
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fit — auto-fit text into a box
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fit `body` into a `w` x `h` box, returning the styled content.
|
||||
///
|
||||
/// `w` and `h` MUST be absolute lengths (mm/pt). Inside `page(background:)` or a
|
||||
/// `layout()` block use `safe-area(spec)`'s mm keys; percentages cannot be measured.
|
||||
///
|
||||
/// Strategy, and the whole reason this helper exists: when `family` exposes a `wdth`
|
||||
/// axis we bisect on WIDTH FIRST, keeping the requested size and narrowing the letters
|
||||
/// until the line fits. Narrowing preserves a poster's optical weight — the headline
|
||||
/// still fills its band and still reads from across the piazza. Shrinking destroys it:
|
||||
/// a 40 % smaller title is a 40 % quieter poster. Only once the axis bottoms out at its
|
||||
/// minimum do we fall back to bisecting the size.
|
||||
///
|
||||
/// - body: content to typeset
|
||||
/// - w, h: absolute box size
|
||||
/// - family: font family string, or `none` to inherit
|
||||
/// - axes: axis ranges for that family, e.g. `(wdth: (62, 125))` — `none`/`(:)` is fine
|
||||
/// - size: the ideal (maximum) size; the fit never grows past it
|
||||
/// - min-size: hard floor; defaults to 28 % of `size`, never below 6pt
|
||||
/// - steps: bisection iterations (~12 is well past visual convergence)
|
||||
#let fit(
|
||||
body,
|
||||
w,
|
||||
h,
|
||||
family,
|
||||
axes,
|
||||
size: 72pt,
|
||||
min-size: none,
|
||||
weight: none,
|
||||
tracking: none,
|
||||
fill: none,
|
||||
leading: 0.34em,
|
||||
justify: false,
|
||||
align-to: left,
|
||||
steps: 12,
|
||||
) = context {
|
||||
let axes = if type(axes) == dictionary { axes } else { (:) }
|
||||
let wdth = _axis-range(axes.at("wdth", default: none))
|
||||
let floor = if min-size != none { min-size } else {
|
||||
calc.max(6pt, size * 0.28)
|
||||
}
|
||||
let floor = calc.min(floor, size)
|
||||
|
||||
// One trial layout, measured inside the real box. `measure` with a region makes the
|
||||
// paragraph wrap exactly as it will on the page, so multi-line titles are handled.
|
||||
let trial(sz, wd) = {
|
||||
set par(leading: leading, linebreaks: "optimized", justify: justify)
|
||||
set align(align-to)
|
||||
text(.._text-args(family, sz, weight, tracking, fill, wd), body)
|
||||
}
|
||||
let fits(sz, wd) = {
|
||||
let m = measure(width: w, height: h, trial(sz, wd))
|
||||
// 0.01pt slack absorbs the last bit of floating-point noise in the bisection.
|
||||
m.width <= w + 0.01pt and m.height <= h + 0.01pt
|
||||
}
|
||||
|
||||
let final-size = size
|
||||
let final-wdth = none
|
||||
|
||||
if wdth != none and wdth.at(0) < wdth.at(1) {
|
||||
let lo = calc.min(wdth.at(0), wdth.at(1)) // narrowest, most likely to fit
|
||||
let hi = calc.max(wdth.at(0), wdth.at(1)) // widest, what we would rather keep
|
||||
if fits(size, hi) {
|
||||
final-wdth = hi
|
||||
} else if not fits(size, lo) {
|
||||
// The axis bottomed out: stay narrow and buy the rest back by shrinking.
|
||||
final-wdth = lo
|
||||
let slo = floor
|
||||
let shi = size
|
||||
let i = 0
|
||||
while i < steps {
|
||||
let mid = (slo + shi) / 2
|
||||
if fits(mid, lo) { slo = mid } else { shi = mid }
|
||||
i += 1
|
||||
}
|
||||
final-size = slo
|
||||
} else {
|
||||
// Widest that still fits at full size.
|
||||
let i = 0
|
||||
while i < steps {
|
||||
let mid = (lo + hi) / 2
|
||||
if fits(size, mid) { lo = mid } else { hi = mid }
|
||||
i += 1
|
||||
}
|
||||
final-wdth = lo
|
||||
}
|
||||
} else {
|
||||
if wdth != none { final-wdth = wdth.at(0) }
|
||||
if not fits(size, final-wdth) {
|
||||
let slo = floor
|
||||
let shi = size
|
||||
let i = 0
|
||||
while i < steps {
|
||||
let mid = (slo + shi) / 2
|
||||
if fits(mid, final-wdth) { slo = mid } else { shi = mid }
|
||||
i += 1
|
||||
}
|
||||
final-size = slo
|
||||
}
|
||||
}
|
||||
|
||||
trial(final-size, final-wdth)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scrim — the gradient wash that buys legibility over busy artwork
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A gradient rectangle drawn BEHIND a text band: opaque at the band's outer edge,
|
||||
/// fading to nothing over the artwork. Draw it, then the text, then nothing else.
|
||||
///
|
||||
/// - height: band height (absolute length or a ratio of the container)
|
||||
/// - colour: the wash colour — normally `palette.scrim`; `none` falls back to black
|
||||
/// - angle: gradient direction. `none` -> 90deg, i.e. transparent at top, solid at the
|
||||
/// bottom, which is what a bottom-anchored text band wants. Use 270deg for a top band.
|
||||
///
|
||||
/// The mid stop is deliberately not linear: a straight ramp reads as a grey haze across
|
||||
/// the whole image, whereas holding the fade back until ~45 % keeps the artwork clean.
|
||||
#let scrim(height, colour, angle, width: 100%, strength: 100%) = {
|
||||
let c = if colour == none { black } else { hex(colour, fallback: black) }
|
||||
let a = if angle == none { 90deg } else { angle }
|
||||
let solid = if strength >= 100% { c } else { c.transparentize(100% - strength) }
|
||||
rect(
|
||||
width: width,
|
||||
height: height,
|
||||
stroke: none,
|
||||
fill: gradient.linear(
|
||||
(c.transparentize(100%), 0%),
|
||||
(c.transparentize(78%), 45%),
|
||||
(c.transparentize(30%), 75%),
|
||||
(solid, 100%),
|
||||
angle: a,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// safe-area — the usable rect
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The rect that content may occupy: the trim, inset by the safe margin.
|
||||
///
|
||||
/// Returns absolute mm lengths AND fractions, because the two live in different
|
||||
/// coordinate systems and mixing them is the classic bleed bug:
|
||||
/// * `x`/`y`/`width`/`height` are offsets from the FULL page origin (top-left of the
|
||||
/// bleed), which is exactly what `page(background:)` and `page(foreground:)`
|
||||
/// resolve against.
|
||||
/// * `fx`/`fy`/`fw`/`fh` are the same rect as fractions of the FULL page, for
|
||||
/// percentage-based placement.
|
||||
/// * `tw`/`th` are the safe rect as fractions of the TRIM — the resolution-independent
|
||||
/// numbers to build layout ratios from.
|
||||
#let safe-area(spec) = {
|
||||
let t = trim-mm(spec)
|
||||
let bleed = _dig(spec, ("page", "bleedMm"), 0)
|
||||
let safe = _dig(spec, ("page", "safeMm"), 0)
|
||||
|
||||
// A safe margin can never eat more than 40 % of an edge, whatever the config says.
|
||||
let safe = calc.max(0, calc.min(safe, calc.min(t.width, t.height) * 0.4))
|
||||
let bleed = calc.max(0, bleed)
|
||||
|
||||
let full-w = t.width + 2 * bleed
|
||||
let full-h = t.height + 2 * bleed
|
||||
let inset = bleed + safe
|
||||
let usable-w = t.width - 2 * safe
|
||||
let usable-h = t.height - 2 * safe
|
||||
|
||||
(
|
||||
// absolute, from the full-page origin
|
||||
x: inset * 1mm,
|
||||
y: inset * 1mm,
|
||||
width: usable-w * 1mm,
|
||||
height: usable-h * 1mm,
|
||||
// the boxes this rect sits inside
|
||||
trim-width: t.width * 1mm,
|
||||
trim-height: t.height * 1mm,
|
||||
full-width: full-w * 1mm,
|
||||
full-height: full-h * 1mm,
|
||||
bleed: bleed * 1mm,
|
||||
safe: safe * 1mm,
|
||||
short-edge: calc.min(t.width, t.height) * 1mm,
|
||||
// fractions of the full page (bleed included)
|
||||
fx: inset / full-w * 100%,
|
||||
fy: inset / full-h * 100%,
|
||||
fw: usable-w / full-w * 100%,
|
||||
fh: usable-h / full-h * 100%,
|
||||
// fractions of the trim — the stable numbers for layout ratios
|
||||
tw: usable-w / t.width * 100%,
|
||||
th: usable-h / t.height * 100%,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// crop-marks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Eight short rules marking the trim corners, for `page(foreground: ...)`.
|
||||
///
|
||||
/// Off by default (`spec.page.cropMarks`), because web-to-print services reject them;
|
||||
/// offset houses want them. Each mark runs from a trim corner outward to the page edge,
|
||||
/// so it lives entirely in the bleed and never touches the trimmed piece. With no bleed
|
||||
/// there is nowhere to put them and nothing is drawn.
|
||||
///
|
||||
/// Drawn in plain black: this is an RGB pipeline, and cmyk() registration black is
|
||||
/// exactly the kind of colour that breaks the PDF (see the header note).
|
||||
#let crop-marks(spec) = {
|
||||
if not _dig(spec, ("page", "cropMarks"), false) { return none }
|
||||
let bleed = _dig(spec, ("page", "bleedMm"), 0)
|
||||
if bleed <= 0 { return none }
|
||||
|
||||
let t = trim-mm(spec)
|
||||
let b = bleed * 1mm
|
||||
let tw = t.width * 1mm
|
||||
let th = t.height * 1mm
|
||||
let s = 0.25pt + black
|
||||
let hmark(x, y) = place(dx: x, dy: y, line(length: b, angle: 0deg, stroke: s))
|
||||
let vmark(x, y) = place(dx: x, dy: y, line(length: b, angle: 90deg, stroke: s))
|
||||
|
||||
hmark(0pt, b) // top-left, horizontal
|
||||
vmark(b, 0pt) // top-left, vertical
|
||||
hmark(b + tw, b) // top-right, horizontal
|
||||
vmark(b + tw, 0pt) // top-right, vertical
|
||||
hmark(0pt, b + th) // bottom-left, horizontal
|
||||
vmark(b, b + th) // bottom-left, vertical
|
||||
hmark(b + tw, b + th) // bottom-right, horizontal
|
||||
vmark(b + tw, b + th) // bottom-right, vertical
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// block-style — role to typography
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Ratios are fractions of the TRIM'S SHORT EDGE, so a headline is the same physical
|
||||
// fraction of the poster at any format or dpi. The title dominates by roughly 8x the
|
||||
// footer, which is what makes a poster readable at distance and legal up close.
|
||||
#let _ROLE-STYLES = (
|
||||
title: (ratio: 0.130, kind: "display", weight: 800, tracking: -0.015em, leading: 0.30em, upper: true, ink: "ink"),
|
||||
subtitle: (ratio: 0.052, kind: "display", weight: 500, tracking: 0.000em, leading: 0.42em, upper: false, ink: "ink"),
|
||||
date: (ratio: 0.046, kind: "body", weight: 700, tracking: 0.020em, leading: 0.40em, upper: true, ink: "accent"),
|
||||
venue: (ratio: 0.036, kind: "body", weight: 500, tracking: 0.030em, leading: 0.45em, upper: true, ink: "ink"),
|
||||
details: (ratio: 0.024, kind: "body", weight: 400, tracking: 0.010em, leading: 0.60em, upper: false, ink: "ink"),
|
||||
price: (ratio: 0.032, kind: "body", weight: 700, tracking: 0.020em, leading: 0.45em, upper: false, ink: "accent"),
|
||||
footer: (ratio: 0.016, kind: "body", weight: 400, tracking: 0.060em, leading: 0.70em, upper: true, ink: "muted"),
|
||||
)
|
||||
|
||||
/// Typography for a block role. Unknown roles degrade to `details` rather than failing.
|
||||
///
|
||||
/// Returns:
|
||||
/// ratio fraction of the short edge used for `size`
|
||||
/// size absolute ideal size (the maximum `fit` will use)
|
||||
/// min-size floor for the auto-fit
|
||||
/// family resolved family name, or `none` to inherit
|
||||
/// axes that family's variable axes, ready for `fit`
|
||||
/// upper whether the template should uppercase the text
|
||||
/// args spreadable straight into `fit`: `fit(body, w, h, st.family, st.axes, ..st.args)`
|
||||
#let block-style(spec, role) = {
|
||||
let key = if type(role) == str and role in _ROLE-STYLES { role } else { "details" }
|
||||
let s = _ROLE-STYLES.at(key)
|
||||
let pal = palette-of(spec)
|
||||
let fill = if s.ink == "accent" { pal.accent } else if s.ink == "muted" {
|
||||
pal.ink.transparentize(30%)
|
||||
} else { pal.ink }
|
||||
|
||||
let family = font-of(spec, s.kind)
|
||||
let size = short-edge-mm(spec) * s.ratio * 1mm
|
||||
// Titles may shrink hard; small roles must stay legible, so their floor is tight.
|
||||
let min-size = if key == "title" { size * 0.32 } else if key == "subtitle" {
|
||||
size * 0.55
|
||||
} else { size * 0.8 }
|
||||
|
||||
(
|
||||
role: key,
|
||||
ratio: s.ratio,
|
||||
size: size,
|
||||
min-size: calc.max(5pt, min-size),
|
||||
family: family,
|
||||
axes: font-axes(spec, family),
|
||||
weight: s.weight,
|
||||
tracking: s.tracking,
|
||||
leading: s.leading,
|
||||
fill: fill,
|
||||
upper: s.upper,
|
||||
args: (
|
||||
size: size,
|
||||
min-size: calc.max(5pt, min-size),
|
||||
weight: s.weight,
|
||||
tracking: s.tracking,
|
||||
leading: s.leading,
|
||||
fill: fill,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Every block with the given role, in spec order. Returns `()` when there are none —
|
||||
/// templates should treat an absent role as "draw nothing", not as an error.
|
||||
#let blocks-of(spec, role) = {
|
||||
let bs = _get(spec, "blocks", ())
|
||||
if type(bs) != array { return () }
|
||||
bs.filter(b => type(b) == dictionary and _get(b, "role", none) == role)
|
||||
}
|
||||
|
||||
/// The text of the first block with `role`, uppercased when the role calls for it.
|
||||
/// `none` when the role is absent.
|
||||
#let block-text(spec, role, upper: auto) = {
|
||||
let bs = blocks-of(spec, role)
|
||||
if bs.len() == 0 { return none }
|
||||
let t = _get(bs.at(0), "text", "")
|
||||
if type(t) != str or t.trim() == "" { return none }
|
||||
let up = if upper == auto { block-style(spec, role).upper } else { upper }
|
||||
if up { upper(t) } else { t }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// logo-place
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Place `spec.logo` in its corner, sized to `spec.logo.scale` of the trim's short
|
||||
/// edge, inset to the safe area. For `page(foreground: ...)`, whose origin is the full
|
||||
/// page including bleed — which is why the inset is bleed + safe, not just safe.
|
||||
///
|
||||
/// Optional throughout: no `logo`, no `path`, or an empty path all draw nothing.
|
||||
/// NOTE: `path` must be root-relative (leading "/") — Typst resolves image paths
|
||||
/// against `--root`, not against the filesystem root, so the renderer rewrites it.
|
||||
#let logo-place(spec, margin: none) = {
|
||||
let logo = _get(spec, "logo", none)
|
||||
if type(logo) != dictionary { return none }
|
||||
let path = _get(logo, "path", none)
|
||||
if type(path) != str or path.trim() == "" { return none }
|
||||
|
||||
let sa = safe-area(spec)
|
||||
let corner = _get(logo, "corner", "br")
|
||||
let scale = _get(logo, "scale", 0.12)
|
||||
if type(scale) not in (int, float) { scale = 0.12 }
|
||||
let scale = calc.max(0.02, calc.min(0.4, scale))
|
||||
let w = sa.short-edge * scale
|
||||
|
||||
let inset = if margin != none { margin } else { sa.x }
|
||||
let insety = if margin != none { margin } else { sa.y }
|
||||
|
||||
let spot = (
|
||||
tl: (top + left, inset, insety),
|
||||
tr: (top + right, -inset, insety),
|
||||
bl: (bottom + left, inset, -insety),
|
||||
br: (bottom + right, -inset, -insety),
|
||||
).at(corner, default: (bottom + right, -inset, -insety))
|
||||
|
||||
place(
|
||||
spot.at(0),
|
||||
dx: spot.at(1),
|
||||
dy: spot.at(2),
|
||||
image(path, width: w, fit: "contain"),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user