Przejdź do treści
Biblioteka skilli
Dokumenty i biuro · Konwersja i formaty

handwrite

/handwrite

Tworzy czcionkę ręcznego pisma z próbki użytkownika i zapisuje dokumenty w tym piśmie jako pliki PDF gotowe do druku.

Repozytorium
handwrite ↗
Licencja
MIT
Ostatnia zmiana repozytorium
11.11.2026

Ocena i bezpieczeństwo

95/ 100Klasa: dobry
Format25 / 25
Frontmatter z nazwą i opisem, poprawna nazwa, opis do 1024 znaków, nazwa zgodna z folderem.
Odkrywalność25 / 25
Opis mówi, kiedy użyć skilla, ma sensowną długość i jest w 3. osobie.
Treść30 / 35
Co najmniej 150 słów i do 500 linii, nagłówki, listy kroków, przykłady albo kod.
Higiena15 / 15
Bez zaślepek typu TODO, bez ścieżek Windows, z licencją.
Ryzykobrak sygnałów w skanie
Pliki w paczce
1
Skrypty
0
Komentarze HTML
3
Adresy URL w SKILL.md
0
Przestarzałość

Brak sygnałów. Repozytorium zmienione 11.11.2026, bez odwołań do wycofanych modeli i API.

Licencja

MIT, otwarta: można używać i zmieniać, z zachowaniem informacji o autorze.

Co sprawdzić przed instalacją
  • SKILL.md zawiera komentarze HTML (3). Nie widać ich w podglądzie Markdown, a model je czyta.

Co robi i kiedy

Tworzy czcionkę ręcznego pisma z próbki użytkownika i zapisuje dokumenty w tym piśmie jako pliki PDF gotowe do druku.

Oryginalny opis (EN)

Turn a sample of the user's handwriting into a font, then write documents out in that handwriting as print-ready PDFs. Use for /handwrite or requests for a handwriting font or handwritten document.

Jak działa

  1. 0. Set up (once per session)
  2. 1. Get the handwriting
  3. 2. Build the font
  4. 3. Find out what to write
  5. 4. Turn the content into a spec
  6. 5. Render and check
  7. Scripts
Sekcje z nagłówków SKILL.md, w oryginale · 873 linii, 9 982 słów
Podgląd SKILL.md
---
name: handwrite
description: Turn a sample of the user's handwriting into a font, then write documents out in that handwriting as print-ready PDFs. Use for /handwrite or requests for a handwriting font or handwritten document.
---

# Handwrite

Builds a font from the user's own handwriting (7 rotating versions of every character, so repeats never look identical), then writes any document in it: a described document or an uploaded one. Two bundled scripts do the work; your job is the conversation, labelling, converting content, and checking results by eye.

Only for the user's own handwriting (or handwriting they have permission to use). Do not reproduce signatures, and do not help pass writing off as another person's.

## 0. Set up (once per session)

Write the two scripts at the bottom of this file to the working directory. Do not retype them: extract them from this file (its folder is the "Base directory" shown when the skill loads).

```bash
pip install --break-system-packages opencv-python-headless numpy scikit-image fonttools reportlab Hershey-Fonts pillow pypdfium2 2>&1 | tail -1
python3 - "<base directory>/SKILL.md" <<'EOF'
import re,sys
t=open(sys.argv[1],encoding='utf-8').read()
for name,body in re.findall(r'<!-- file: (\S+) -->\n```python\n(.*?)\n```', t, re.S):
    open(name,'w',encoding='utf-8').write(body+'\n'); print('wrote',name,len(body))
EOF
python3 -m py_compile hw_font.py hw_doc.py && echo OK
```

If the file cannot be read from disk, write both scripts out by hand exactly as shown, then run `py_compile`; fix any typo it reports.

## 1. Get the handwriting

Ask what they have. Three routes:

- **They already have a font from this skill** (a `.ttf` from an earlier run): skip to step 3.
- **Template (best quality, recommended):** run `python3 hw_font.py template handwriting-template.pdf` and send them the PDF. They print both pages at 100%, write each character three times in the boxes (sitting on the lower guide line), and send back a photo or scan of each page. All four black corner squares must be visible; order and rotation do not matter.
- **Any photo of their handwriting:** a page of notes works. More writing means more real characters; isolated, unjoined letters work best.

Offer the template, but start with whatever they send. Never block on it.

## 2. Build the font

Name the font file after something neutral or what the user asks for, e.g. `MyHandwriting.ttf`. Building takes 1 to 2 minutes.

**From template photos**

```bash
python3 hw_font.py from-template MyHandwriting.ttf page1.jpg page2.jpg
```

If it reports that the corner squares were not found, ask for a flatter, better-lit photo with the whole page in frame.

**From any photo**

```bash
python3 hw_font.py segment sample.jpg work1      # makes work1/sheet0.png, sheet1.png, ...
```

Look at every `sheet*.png`. Each cell is one cut-out piece with its number underneath, in reading order, so you can follow along with the text of the page. Then write `work1/labels.json`:

```json
{"labels":  {"a": [51, 190], "x": [22, 108], "λ": [61], "(": [110]},
 "largest": {"e": [202]},
 "splits":  {"370": "ch", "246": "C_"}}
```

- `labels`: piece numbers that show exactly one clean character, best sample first. Up to 7 per character are used.
- `largest`: pieces where only the biggest blob is the character (a letter with a stray mark attached).
- `splits`: a piece containing several joined letters, cut into those letters at the thinnest points. Use `_` for a part you want to discard.

Labelling rules: read the page text first so you know what each piece should be. Skip pieces that are blobs, fragments, table lines, or several letters fused beyond a clean cut. Be careful with look-alikes (l / 1 / I / |, o / O / 0, s / S, x / X): use the surrounding text and size to decide, and skip when unsure. A wrong label is worse than a missing one, because missing characters are drawn automatically in a matching weight. Then:

```bash
python3 hw_font.py from-labels MyHandwriting.ttf work1          # several work folders may be listed
```

If the user later sends more samples, segment each into its own folder, label it, and rebuild with all folders listed.

**Check it.** The build prints how many real samples each character has and which basic characters had to be drawn. Then:

```bash
python3 hw_doc.py --preview MyHandwriting.ttf preview.pdf && pdftoppm -r 70 -png preview.pdf preview
```

Look at the preview image. If a character is clearly wrong (mislabelled, upside down, a blob), fix `labels.json` and rebuild. Send the user the `.ttf` and the preview, and tell them plainly: which characters came from their writing, which were drawn to match, and which rest on a single weak sample. Tell them to keep the `.ttf` so they can skip this step next time. In Word, repeated letters vary once "Use Contextual Alternates" is ticked under Font > Advanced.

## 3. Find out what to write

Ask (in one go, with a question tool if there is one):

1. **What to write:** describe a document, or upload one (PDF, Word, text, image).
2. **Writing tool:** pencil (default), pen, or marker.
3. **Extras:** a few smudges (pencil only), ruled notebook lines, a dotted divider between columns.

If they already said any of this, do not ask again.

## 4. Turn the content into a spec

Write `spec.json` for `hw_doc.py` (run it with no arguments to see the full format):

```json
{"page":  {"size": "letter", "columns": 1, "margin": 36, "font_size": 13},
 "style": {"tool": "pencil", "smudges": true, "divider": false, "ruled": false},
 "blocks": [
   {"h1": "Page title"},
   {"h2": "Underlined section heading"},
   {"h3": "Plain sub-heading"},
   {"p": "A paragraph. Subscripts H_{2}O, superscripts e^{-λx}, symbols typed directly: ≤ ≠ √ ∑ ∫ α β π."},
   {"list": ["first point", "second point"], "ordered": false},
   {"table": [["Header A", "Header B"], ["cell", "cell"], ["a row with fewer cells spans the rest"]], "widths": [0.3, 0.7], "header": true},
   {"note": "smaller text"}, {"space": 1}, {"break": "column"}
 ]}
```

- **Described document:** write the content yourself as you normally would, then express it in blocks.
- **Uploaded document:** read it fully and transcribe it faithfully. Do not summarise, drop or reword anything. **Match the original layout:** same number of columns, the same tables with the same column proportions (`widths`), the same order, and the same page count by setting `"font_size": "fit"` and `"pages"` to the original's page count. Tight sources may need `"margin": 20` to 24.
- Decorative formatting does not carry over (colour, bold, shading); headings become `h1`/`h2`/`h3`.
- Maths: use `_{}` and `^{}` and Unicode symbols. Bars and hats are a letter followed by a combining mark (x + U+0304, p + U+0302). There are no stacked fractions; write a/b.
- Natural handwriting is about 12 to 14 pt. Sizes under 9 pt read as tiny, very neat writing; say so if the layout forces that.

## 5. Render and check

```bash
python3 hw_doc.py MyHandwriting.ttf spec.json Handwritten.pdf
pdftoppm -r 60 -png Handwritten.pdf check        # then look at every page
```

Read the script's output: it reports the font size, the page count, and any characters the font lacks (written as "?" or without their accent). Replace or respell those and rerun. Look at each page image for text running outside its cell, awkward table widths (set `widths` yourself), or a page count that differs from the original. For a pencil render also zoom into one area (`pdftoppm -r 200 -x .. -y .. -W 1200 -H 800`) to confirm it is legible.

Style notes:
- **pencil** produces an image-based PDF (grey graphite, grain, uneven pressure; text is not selectable; about 1 to 3 MB per page at the default 450 dpi). `"darkness": 1.2` prints darker.
- **pen** and **marker** produce crisp vector PDFs. `"ink": "#1b2a5c"` sets the pen colour.
- `"jitter"` controls messiness: 0.5 neat, 1.0 natural, 1.5 loose.
- `"ruled": true` snaps every line of writing onto notebook rules.

Send the PDF with one or two lines: page count, text size, tool, and anything the user should know (drawn or weak characters that appear a lot, characters that were substituted). Tell them to print at 100% / actual size. Then offer changes: darker, neater, different tool, extras on or off.

## Scripts

<!-- file: hw_font.py -->
```python
#!/usr/bin/env python3
"""hw_font.py - build a handwriting font (7 rotating variants per character) from a sample.
  template OUT.pdf                      printable 2-page sheet to fill in
  from-template FONT.ttf SCAN [SCAN..]  build the font from photos/scans of the filled sheet
  segment IMAGE WORKDIR                 any handwriting photo -> numbered contact sheets to label
  from-labels FONT.ttf WORKDIR [WORKDIR..]   build from WORKDIR/labels.json written after `segment`
Needs: opencv-python-headless numpy scikit-image fonttools reportlab Hershey-Fonts"""
import cv2, numpy as np, pickle, math, sys, json, os
PX = 0.5; XH = 470; CH = 700; NV = 7          # canvas px per font unit, x-height, cap height, variants
TCHARS = list("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.,;:!?'\"()-+=/%&@#$*<>[]") + list("±×÷√∑∫∞≤≥≠≈→πθλμσαβΔΣΦ")
DPI = 300; K = DPI / 72.0                     # template geometry is in points, top-left origin
GW, LW, CW_, RH, GX, GY, ROWS, BASE, XHPT = 135, 15, 40, 48, 36, 62, 14, 15, 11.5
FID = [(24, 24), (572, 24), (24, 752), (572, 752)]; FS = 16
U_PER_PX = XH / (XHPT * K)                    # font units per template pixel

def norm_gray(g):
    """ink map independent of lighting: gray divided by local paper brightness (0..200)"""
    k = max(15, int(min(g.shape) / 40) | 1)
    bg = cv2.GaussianBlur(cv2.morphologyEx(g, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))), (0, 0), k / 2)
    return np.clip(g.astype(np.float32) / np.maximum(bg, 1) * 200, 0, 255).astype(np.uint8)

def trim(a):
    ys, xs = np.where(a)
    return a[ys.min():ys.max() + 1, xs.min():xs.max() + 1]

# ------------------------------------------------------------------ template
def cell_rect(i, s):
    """rect (x,y,w,h) in points for char index i (0..55 on a page), sample s (0..2)"""
    r, q = divmod(i, 4)
    return GX + q * GW + LW + s * CW_, GY + r * RH, CW_, RH

def make_template(out):
    from reportlab.pdfgen import canvas
    c = canvas.Canvas(out, pagesize=(612, 792)); per = ROWS * 4
    for p in range((len(TCHARS) + per - 1) // per):
        c.setFillGray(0)
        for x, y in FID: c.rect(x, 792 - y - FS, FS, FS, fill=1, stroke=0)
        c.rect(48, 792 - 24 - FS, 8, FS, fill=1, stroke=0)                 # orientation mark
        if p: c.rect(64, 792 - 24 - FS, 8, FS, fill=1, stroke=0)           # page-2 mark
        c.setFont('Helvetica', 8.5)
        c.drawString(84, 792 - 37, f"Handwriting sample {p+1}/2: write each character 3 times, sitting on the lower guide line. Small letters reach the upper line.")
        for i, ch in enumerate(TCHARS[p * per:(p + 1) * per]):
            x, y, w, h = cell_rect(i, 0)
            c.setFillGray(0); c.setFont('Helvetica', 9); c.drawCentredString(x - LW / 2, 792 - y - h + BASE + 2, '' if ch == 'Δ' else ch)
            if ch == 'Δ': c.setStrokeGray(0); c.setLineWidth(0.7); p = c.beginPath(); p.moveTo(x - LW / 2 - 3.2, 792 - y - h + BASE + 2); p.lineTo(x - LW / 2, 792 - y - h + BASE + 8.5); p.lineTo(x - LW / 2 + 3.2, 792 - y - h + BASE + 2); p.close(); c.drawPath(p)   # the built-in label fonts have no capital delta
            for s in range(3):
                x, y, w, h = cell_rect(i, s); Y = 792 - y - h
                c.setStrokeGray(0.74); c.setLineWidth(0.5); c.rect(x, Y, w, h, fill=0, stroke=1)
                c.setStrokeGray(0.84); c.setLineWidth(0.4); c.line(x, Y + BASE, x + w, Y + BASE)
                c.setDash(1.5, 2); c.line(x, Y + BASE + XHPT, x + w, Y + BASE + XHPT); c.setDash()
        c.showPage()
    c.save(); print('wrote', out)

def read_template(path):
    """-> (page_no, {char: [(bitmap, bottom_units), ...]})"""
    g = cv2.imread(path, 0); assert g is not None, path
    if max(g.shape) > 4200: g = cv2.resize(g, None, fx=4200 / max(g.shape), fy=4200 / max(g.shape), interpolation=cv2.INTER_AREA)
    n = norm_gray(g); H, W = g.shape
    f = 1200 / max(H, W); sm = cv2.GaussianBlur(cv2.resize(g, None, fx=f, fy=f, interpolation=cv2.INTER_AREA), (0, 0), 1)   # corner squares: dark vs. brightest nearby paper
    cs, _ = cv2.findContours((sm < 0.5 * cv2.dilate(sm, np.ones((121, 121), np.uint8))).astype(np.uint8), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
    sq = []
    for c in cs:
        a = cv2.contourArea(c); (cx, cy), (w, h), _ = cv2.minAreaRect(c)
        if a > 60 and min(w, h) > 0 and max(w, h) / min(w, h) < 1.35 and a / (w * h) > 0.8: sq.append((cx / f, cy / f, a))
    assert len(sq) >= 4, f'{path}: could not find the 4 corner squares - retake the photo flat, whole page visible'
    med = np.median([s[2] for s in sorted(sq, key=lambda s: -s[2])[:4]]); sq = [s for s in sq if 0.4 * med < s[2] < 2.5 * med]
    P = np.array([s[:2] for s in sq], np.float32)
    corners = [P[np.argmin(P[:, 0] + P[:, 1])], P[np.argmax(P[:, 0] - P[:, 1])], P[np.argmin(P[:, 0] - P[:, 1])], P[np.argmax(P[:, 0] + P[:, 1])]]
    dst = np.array([[(x + FS / 2) * K, (y + FS / 2) * K] for x, y in FID], np.float32); best = None
    for rot in ([0, 1, 2, 3], [1, 3, 0, 2], [3, 2, 1, 0], [2, 0, 3, 1]):       # find the orientation mark
        M = cv2.getPerspectiveTransform(np.array([corners[i] for i in rot], np.float32), dst)
        w = cv2.warpPerspective(n, M, (int(612 * K), int(792 * K)), borderValue=200)
        dark = w[int(26 * K):int(38 * K), int(49 * K):int(55 * K)].mean()
        if best is None or dark < best[0]: best = (dark, w)
    w = best[1]; page = 1 if w[int(26 * K):int(38 * K), int(65 * K):int(71 * K)].mean() < 110 else 0
    out = {}; per = ROWS * 4
    for i, ch in enumerate(TCHARS[page * per:(page + 1) * per]):
        for s in range(3):
            x, y, cw, h = cell_rect(i, s); m = 3.2
            x0, y0, x1, y1 = int((x + m) * K), int((y + m) * K), int((x + cw - m) * K), int((y + h - m) * K)
            ink = (w[y0:y1, x0:x1] < 128).astype(np.uint8)
            nc, lab, st, _ = cv2.connectedComponentsWithStats(ink)
            for j in range(1, nc):
                if st[j, 4] < 14: ink[lab == j] = 0
            if ink.sum() < 45: continue
            ys, xs = np.where(ink); bm = ink[ys.min():ys.max() + 1, xs.min():xs.max() + 1]
            base_px = (y + h - BASE) * K - y0
            f = U_PER_PX * PX
            b = cv2.resize(bm.astype(np.float32), None, fx=f, fy=f, interpolation=cv2.INTER_CUBIC)
            b = cv2.GaussianBlur(np.pad(b, 12), (0, 0), 0.55 * f) > 0.5
            out.setdefault(ch, []).append((b, (base_px - (ys.max() + 1)) * U_PER_PX))
    return page, out

# ------------------------------------------------------------------ free-form photo
def segment(image, wd):
    os.makedirs(wd, exist_ok=True); g = cv2.imread(image, 0); assert g is not None, image
    if max(g.shape) > 3000: g = cv2.resize(g, None, fx=3000 / max(g.shape), fy=3000 / max(g.shape), interpolation=cv2.INTER_AREA)
    bl = cv2.GaussianBlur(g, (0, 0), 8); paper = (bl > 0.6 * np.percentile(bl, 97)).astype(np.uint8)
    nl, lab, st, _ = cv2.connectedComponentsWithStats(paper)
    if nl > 1:
        paper = (lab == 1 + np.argmax(st[1:, 4])).astype(np.uint8)
        paper = cv2.erode(cv2.morphologyEx(paper, cv2.MORPH_CLOSE, np.ones((151, 151), np.uint8)), np.ones((21, 21), np.uint8))
    n = norm_gray(g); ink = ((n < 124) & (paper > 0)).astype(np.uint8)
    _, _, st0, _ = cv2.connectedComponentsWithStats(ink)
    hs = [s[3] for s in st0[1:] if s[4] > 20 and s[3] < g.shape[0] / 8]
    S = float(np.median(hs)) / 30 if hs else 1.0                    # scale relative to ~30px letters
    L = int(90 * S) | 1
    lines = cv2.dilate(cv2.morphologyEx(ink, cv2.MORPH_OPEN, np.ones((1, L), np.uint8)), np.ones((7, 3), np.uint8)) | \
            cv2.dilate(cv2.morphologyEx(ink, cv2.MORPH_OPEN, np.ones((L, 1), np.uint8)), np.ones((3, 7), np.uint8))
    txt = ink & (1 - lines)                                         # ruled/table lines removed
    nl, lab, st, _ = cv2.connectedComponentsWithStats(txt, connectivity=8); comps = []
    for i in range(1, nl):
        x, y, w, h, a = st[i]
        if a < 12 * S * S or w > 260 * S or h > 120 * S: continue
        comps.append(dict(ids=[i], x=int(x), y=int(y), w=int(w), h=int(h), a=int(a)))
    comps.sort(key=lambda c: c['a']); used = [False] * len(comps)
    for i, c in enumerate(comps):                                   # attach dots / stacked bars (i j = : ; ! ?)
        if c['h'] < 16 * S or c['a'] < 90 * S * S:
            best = None
            for j, d in enumerate(comps):
                if j == i or used[j]: continue
                o = min(c['x'] + c['w'], d['x'] + d['w']) - max(c['x'], d['x'])
                gap = max(d['y'] - (c['y'] + c['h']), c['y'] - (d['y'] + d['h']))
                if o >= 0.5 * min(c['w'], d['w']) and gap < 22 * S and d['h'] < 75 * S and (best is None or gap < best[0]): best = (gap, j)
            if best:
                d = comps[best[1]]; x0, y0 = min(c['x'], d['x']), min(c['y'], d['y'])
                x1, y1 = max(c['x'] + c['w'], d['x'] + d['w']), max(c['y'] + c['h'], d['y'] + d['h'])
                d.update(ids=d['ids'] + c['ids'], x=x0, y=y0, w=x1 - x0, h=y1 - y0, a=d['a'] + c['a']); used[i] = True
    comps = [c for i, c in enumerate(comps) if not used[i] and c['a'] >= 25 * S * S]
    comps.sort(key=lambda c: c['y'] + c['h'] / 2); rows = []; cur = []; prev = 0
    for c in comps:                                                 # reading order
        cy = c['y'] + c['h'] / 2
        if cur and cy - prev > 9 * S and len(cur) > 3: rows.append(cur); cur = []
        cur.append(c); prev = cy
    rows.append(cur); comps = [c for r in rows for c in sorted(r, key=lambda c: c['x'])]
    pickle.dump((comps, lab, n), open(f'{wd}/comps.pkl', 'wb'))
    C, Z = 14, 84
    for p in range(0, len(comps), C * 8):
        sheet = np.full((8 * (Z + 22), C * Z), 255, np.uint8)
        for k, c in enumerate(comps[p:p + C * 8]):
            m = np.isin(lab[c['y']:c['y'] + c['h'], c['x']:c['x'] + c['w']], c['ids']).astype(np.uint8) * 255
            s = min(1.6 / S, (Z - 10) / max(c['w'], c['h']))
            m = cv2.resize(m, (max(1, int(c['w'] * s)), max(1, int(c['h'] * s))))
            r, q = divmod(k, C); oy = r * (Z + 22) + (Z - m.shape[0]) // 2; ox = q * Z + (Z - m.shape[1]) // 2
            sheet[oy:oy + m.shape[0], ox:ox + m.shape[1]] = 255 - m
            cv2.putText(sheet, str(p + k), (q * Z + 4, r * (Z + 22) + Z + 16), cv2.FONT_HERSHEY_SIMPLEX, 0.5, 90, 1)
            cv2.rectangle(sheet, (q * Z, r * (Z + 22)), (q * Z + Z - 1, r * (Z + 22) + Z + 21), 200, 1)
        cv2.imwrite(f'{wd}/sheet{p // (C * 8)}.png', sheet)
    print(len(comps), 'pieces ->', f'{wd}/sheet*.png (reading order). Now write {wd}/labels.json')

def spec(ch):
    """vertical placement for free-form samples: ('fit', bottom, top) or ('w', centre_y, width)"""
    for chars, sp in (('acemnorsuvwxzσαεικνοπτυω', ('fit', 0, XH)), ('bdhklδθλ', ('fit', 0, 720)), ('t', ('fit', 0, 640)), ('f', ('fit', -60, 720)),
                      ('gpqyγηρχ', ('fit', -230, XH)), ('μ', ('fit', -40, 490)), ('βζξφψ', ('fit', -220, 720)), ('i', ('fit', 0, 670)), ('j', ('fit', -230, 670)),
                      ('()[]{}', ('fit', -150, 780)), ('/', ('fit', -60, 760)), ('∫', ('fit', -130, 770)), ('<>', ('fit', 80, 460)), ('≥≤', ('fit', 40, 520)),
                      ('±', ('fit', 0, 520)), ('+', ('fit', 60, 480)), (':', ('fit', 10, 420)), (';', ('fit', -120, 420)), ('.', ('fit', 0, 90)), (',', ('fit', -120, 80)),
                      ("'\"", ('fit', 560, 770)), ('-', ('w', 270, 300)), ('=', ('w', 270, 370)), ('→', ('w', 270, 580)), ('~', ('w', 270, 420))):
        if ch in chars: return sp
    return ('fit', 0, CH)

def read_labels(wd):
    """labels.json: {"labels": {"a": [12, 57], ...}, "largest": {"e": [202]}, "splits": {"370": "ch"}}
    labels  = piece numbers that are ONE clean character, best first
    largest = pieces where only the biggest blob is the character (e.g. e with a stray bar above it)
    splits  = a piece holding several joined letters, cut at the thinnest points into those letters"""
    comps, lab, n = pickle.load(open(f'{wd}/comps.pkl', 'rb')); J = json.load(open(f'{wd}/labels.json')); KK = 4
    def crop(c, ids=None):
        ids = ids or c['ids']; p = 4
        x0, y0, x1, y1 = max(0, c['x'] - p), max(0, c['y'] - p), c['x'] + c['w'] + p, c['y'] + c['h'] + p
        m = cv2.dilate(np.isin(lab[y0:y1, x0:x1], ids).astype(np.uint8), np.ones((5, 5), np.uint8))
        g = cv2.resize(n[y0:y1, x0:x1], None, fx=KK, fy=KK, interpolation=cv2.INTER_CUBIC)
        return trim((g < 122) & (cv2.resize(m, (g.shape[1], g.shape[0]), interpolation=cv2.INTER_NEAREST) > 0))
    def place(ch, a):
        sp = spec(ch); f = (sp[2] - sp[1]) * PX / a.shape[0] if sp[0] == 'fit' else sp[2] * PX / a.shape[1]
        b = cv2.resize(a.astype(np.float32), None, fx=f, fy=f, interpolation=cv2.INTER_CUBIC)
        b = cv2.GaussianBlur(np.pad(b, 12), (0, 0), max(0.7, 0.55 * f)) > 0.5
        return b, (sp[1] if sp[0] == 'fit' else sp[1] - trim(b).shape[0] / PX / 2)
    out = {}
    for ch, ids in J.get('labels', {}).items():
        for i in ids: out.setdefault(ch, []).append(place(ch, crop(comps[i])))
    for ch, ids in J.get('largest', {}).items():
        for i in ids:
            c = comps[i]; big = [c['ids'][int(np.argmax([(lab == j).sum() for j in c['ids']]))]]
            out.setdefault(ch, []).append(place(ch, crop(c, big)))
    for i, word in J.get('splits', {}).items():
        a = crop(comps[int(i)]); prof = cv2.GaussianBlur(a.sum(0).astype(np.float32).reshape(1, -1), (0, 0), 3).ravel(); Wd = a.shape[1]; cuts = [0]
        for k in range(1, len(word)):
            c0 = int(Wd * k / len(word)); r = int(Wd / len(word) * 0.38); cuts.append(c0 - r + int(np.argmin(prof[c0 - r:c0 + r])))
        cuts.append(Wd)
        for k, ch in enumerate(word):
            if ch != '_' and a[:, cuts[k]:cuts[k + 1]].any(): out.setdefault(ch, []).append(place(ch, trim(a[:, cuts[k]:cuts[k + 1]])))
    return out

# ------------------------------------------------------------------ glyph set
def strokew(b):
    from skimage.morphology import skeletonize
    dt = cv2.distanceTransform(b.astype(np.uint8), cv2.DIST_L2, 5); v = dt[skeletonize(b > 0)]
    return 2 * float(np.median(v)) if len(v) else 0.0

def warp(b, seed, amp=3.3):
    r = np.random.RandomState(seed); b = np.pad(b.astype(np.float32), 24); h, w = b.shape
    fld = lambda: cv2.resize(r.randn(h // 45 + 3, w // 45 + 3).astype(np.float32), (w, h), interpolation=cv2.INTER_CUBIC) * amp
    yy, xx = np.mgrid[0:h, 0:w].astype(np.float32); X = xx - w / 2; Y = yy - h / 2
    ang = math.radians(r.uniform(-2.5, 2.5)); sh = r.uniform(-0.06, 0.06); sx = r.uniform(0.95, 1.05); sy = r.uniform(0.97, 1.03)
    out = cv2.remap(b, (X * math.cos(ang) - Y * math.sin(ang) + sh * Y) / sx + w / 2 + fld(), (X * math.sin(ang) + Y * math.cos(ang)) / sy + h / 2 + fld(), cv2.INTER_LINEAR)
    return cv2.GaussianBlur(out, (0, 0), 0.8) > 0.5

def build_glyphs(samples):
    """samples {ch: [(bitmap, bottom_units)]} -> GL {ch: [NV x dict(bm,bottom,xoff,adv)]}, drawing whatever is missing"""
    from HersheyFonts import HersheyFonts
    samples = {ch: [(b, bt) for b, bt in v if b.any()] for ch, v in samples.items()}; samples = {c: v for c, v in samples.items() if v}
    WT = float(np.median([strokew(b) for v in samples.values() for b, _ in v])); print('stroke width', round(WT / PX), 'units;', len(samples), 'characters from the sample')
    def harmon(b):
        d = (strokew(b) - WT) / 2; r = int(round(min(abs(d) * 0.8, 3 if d > 0 else 7)))
        if r >= 1:
            k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * r + 1, 2 * r + 1)); b2 = (cv2.erode if d > 0 else cv2.dilate)(b.astype(np.uint8), k) > 0
            if d < 0 or cv2.connectedComponents(b2.astype(np.uint8))[0] <= cv2.connectedComponents(b.astype(np.uint8))[0]: b = b2
        return b
    GL = {}
    for ch, v in samples.items():
        v = [(harmon(b), bt) for b, bt in v][:NV]; k = 0; n0 = len(v)
        while len(v) < NV: v.append((warp(v[k % n0][0], 1000 + ord(ch) * 7 + len(v)), v[k % n0][1])); k += 1
        GL[ch] = [dict(bm=trim(b), bottom=bt, xoff=None, adv=None) for b, bt in v]
    # ---- everything below draws characters the sample did not contain
    OX, OY = 500, 950; CWd, CHt = int(2400 * PX), int(1350 * PX); RA = 1.9 * min(1.0, max(0.35, WT / 43))
    P = lambda *p: ('p', [tuple(map(float, q)) for q in p]); C = lambda *p: ('c', [tuple(map(float, q)) for q in p])
    def O(cx, cy, rx, ry, a0=0, a1=360, n=28): return ('c', [(cx + rx * math.cos(math.radians(a0 + (a1 - a0) * i / n)), cy + ry * math.sin(math.radians(a0 + (a1 - a0) * i / n))) for i in range(n + 1)])
    DOT = lambda x, y, s=1.25: ('d', [(x, y)], s)
    def dense(st, step=6):
        p = np.array(st[1], float)
        if st[0] == 'd': return p
        if st[0] == 'c' and len(p) > 2:
            p = np.vstack([p[-2], p, p[1]]) if (np.allclose(p[0], p[-1]) and len(p) > 3) else np.vstack([2 * p[0] - p[1], p, 2 * p[-1] - p[-2]]); out = []
            for i in range(1, len(p) - 2):
                p0, p1, p2, p3 = p[i - 1], p[i], p[i + 1], p[i + 2]
                for t in np.linspace(0, 1, max(2, int(np.linalg.norm(p2 - p1) / step)), endpoint=False):
                    out.append(0.5 * ((2 * p1) + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t * t + (-p0 + 3 * p1 - 3 * p2 + p3) * t ** 3))
            return np.array(out + [p[-2]])
        out = [p[0]]
        for a, b in zip(p[:-1], p[1:]):
            for t in np.linspace(0, 1, max(1, int(np.linalg.norm(b - a) / step)) + 1)[1:]: out.append(a + (b - a) * t)
        return np.array(out)
    def render(strokes, seed, pen, jit):
        r = np.random.RandomState(seed % (2 ** 31)); img = np.zeros((CHt, CWd), np.uint8); pen *= 1.22; jit *= 1.8
        waves = [(r.uniform(5, 9) * jit, r.uniform(-1, 1) / r.uniform(90, 180), r.uniform(-1, 1) / r.uniform(90, 180), r.uniform(0, 6.28), r.uniform(0, 6.28)) for _ in range(3)]
        allp = np.vstack([np.array(s[1], float) for s in strokes]); cx, cy = allp[:, 0].mean(), allp[:, 1].mean()
        ang = math.radians(r.uniform(-2, 2) * jit); sh = r.uniform(-0.04, 0.05) * jit; sx = r.uniform(0.95, 1.05); sy = r.uniform(0.97, 1.03)
        for st in strokes:
            d = dense(st); off = r.uniform(-7, 7, 2) * jit; X = d[:, 0] - cx; Y = d[:, 1] - cy
            x = (X * math.cos(ang) - Y * math.sin(ang) + sh * Y) * sx + cx + off[0]; y = (X * math.sin(ang) + Y * math.cos(ang)) * sy + cy + off[1]
            for a, kx, ky, p1, p2 in waves: x = x + a * np.sin(kx * d[:, 0] + ky * d[:, 1] + p1); y = y + a * np.cos(kx * d[:, 0] - ky * d[:, 1] + p2)
            ps = st[2] if st[0] == 'd' else 1.0; ph = r.uniform(0, 6.28)
            for k in range(len(x)):
                rad = WT / 2 * pen * ps * (1 + 0.2 * math.sin(ph + k * 0.06) + 0.09 * math.sin(ph * 3 + k * 0.21))
                cv2.circle(img, (int(round((x[k] + OX) * PX)), int(round((OY - y[k]) * PX))), max(1, int(round(rad))), 1, -1)
        f = cv2.GaussianBlur(img.astype(np.float32), (0, 0), 1.3)
        f += RA * (cv2.GaussianBlur(r.randn(*img.shape).astype(np.float32), (0, 0), 2.2) * 0.84 + cv2.GaussianBlur(r.randn(*img.shape).astype(np.float32), (0, 0), 7) * 2.24)
        out = (f > 0.5) & (cv2.dilate(img, np.ones((9, 9), np.uint8)) > 0); nc, lab, st, _ = cv2.connectedComponentsWithStats(out.astype(np.uint8))
        for i in range(1, nc):
            if st[i, 4] < 60 * min(1, WT / 43) ** 2: out[lab == i] = False
        return out
    def recsyn(b, keepx=False, adv=None):
        ys, xs = np.where(b); t = b[ys.min():ys.max() + 1, xs.min():xs.max() + 1]
        return dict(bm=t, bottom=OY - (ys.max() + 1) / PX, xoff=(xs.min() / PX - OX) if keepx else None, adv=adv)
    def syn(name, strokes, pen=1.0, keepx=False, adv=None, jit=1.0):
        if name not in GL: GL[name] = [recsyn(render(strokes, sum(map(ord, name)) * 131 + 31 * k, pen, jit), keepx, adv) for k in range(NV)]
    HF = {}
    def hershey(font, c, sx=27.5, sy=33.3):
        if font not in HF: HF[font] = HersheyFonts(); HF[font].load_default_font(font)
        st = [list(s) for s in HF[font].strokes_for_text(c)]; x0 = min(p[0] for s in st for p in s); out = []
        for s in st:
            pts = [((x - x0) * sx, (9 - y) * sy) for x, y in s]; pts = pts * 2 if len(pts) == 1 else pts
            out.append(('c' if len(pts) >= 6 else 'p', pts))
        return out
    A = 270; bar = lambda y, x0=0, x1=420: P((x0, y), (x1, y)); til = lambda y, w=420: C((0, y - 30), (w * .25, y + 45), (w * .5, y), (w * .75, y - 45), (w, y + 30))
    for c in 'ABCDEFGHJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789%()/:;<=>': syn(c, hershey('futural', c))
    syn('I', [P((40, 700), (280, 700)), P((160, 700), (160, 0)), P((40, 0), (280, 0))]); syn('-', [bar(A, 0, 300)])
    syn('!', [P((70, 720), (70, 230)), DOT(70, 25)]); syn('?', [C((30, 560), (80, 690), (220, 720), (320, 620), (300, 470), (190, 360), (170, 220)), DOT(170, 25)])
    syn('"', [P((40, 770), (40, 570)), P((190, 770), (190, 570))]); syn("'", [P((40, 770), (40, 570))]); syn('.', [DOT(60, 35)]); syn(',', [C((80, 60), (90, -20), (30, -130))]); syn('`', [P((30, 770), (130, 630))])
    syn('*', [P((170, 710), (170, 430)), P((50, 640), (290, 500)), P((50, 500), (290, 640))], pen=0.85)
    syn('#', [P((170, 700), (110, 0)), P((400, 700), (340, 0)), P((20, 470), (500, 470)), P((0, 230), (480, 230))], pen=0.8)
    syn('$', hershey('futural', '$', 36, 36), pen=0.8); syn('&', hershey('futural', '&', 36, 34), pen=0.85); syn('@', hershey('futural', '@', 40, 40), pen=0.7)
    syn('+', [bar(A), P((210, 60), (210, 480))]); syn('[', [P((250, 790), (60, 790), (60, -150), (250, -150))]); syn(']', [P((0, 790), (190, 790), (190, -150), (0, -150))])
    syn('\\', [P((0, 760), (280, -60))]); syn('^', [P((20, 480), (170, 700), (320, 480))]); syn('_', [P((0, -120), (480, -120))]); syn('|', [P((60, -150), (60, 800))], jit=0.25); syn('~', [til(A)])
    syn('{', [C((280, 800), (190, 790), (160, 720), (160, 420), (140, 340), (50, 315), (140, 290), (160, 210), (160, -90), (190, -140), (280, -150))])
    syn('}', [C((0, 800), (90, 790), (120, 720), (120, 420), (140, 340), (230, 315), (140, 290), (120, 210), (120, -90), (90, -140), (0, -150))])
    syn('‘', [C((50, 570), (40, 650), (100, 760))]); syn('’', [C((90, 770), (100, 680), (40, 580))])
    syn('“', [C((50, 570), (40, 650), (100, 760)), C((210, 570), (200, 650), (260, 760))]); syn('”', [C((90, 770), (100, 680), (40, 580)), C((250, 770), (260, 680), (200, 580))])
    syn('–', [bar(A, 0, 380)]); syn('—', [bar(A, 0, 760)]); syn('…', [DOT(60, 35), DOT(280, 35), DOT(500, 35)]); syn('•', [DOT(90, A, 1.9)])
    syn('°', [O(110, 640, 75, 75)], pen=0.8); syn('′', [P((80, 790), (30, 570))]); syn('″', [P((80, 790), (30, 570)), P((230, 790), (180, 570))])
    syn('−', [bar(A)]); syn('×', [P((30, 90), (390, 450)), P((30, 450), (390, 90))]); syn('÷', [bar(A), DOT(210, 450), DOT(210, 90)]); syn('·', [DOT(70, A)]); syn('∘', [O(140, A, 95, 95)], pen=0.85)
    syn('∗', [P((170, 420), (170, 120)), P((40, 345), (300, 195)), P((40, 195), (300, 345))], pen=0.85); syn('±', [bar(330, 0, 400), P((200, 520), (200, 140)), bar(20, 0, 400)]); syn('∓', [bar(480, 0, 400), bar(190, 0, 400), P((200, 370), (200, 10))])
    syn('≠', [bar(350), bar(190), P((120, 40), (300, 500))]); syn('≈', [til(350), til(190)]); syn('≃', [til(360), bar(170)]); syn('≅', [til(500), bar(300), bar(110)], pen=0.9); syn('≡', [bar(470), bar(A), bar(70)], pen=0.9)
    syn('≤', [P((400, 540), (20, 360), (400, 180)), bar(30, 20, 400)]); syn('≥', [P((20, 540), (400, 360), (20, 180)), bar(30, 20, 400)])
    syn('≪', [P((300, 480), (0, A), (300, 60)), P((570, 480), (270, A), (570, 60))]); syn('≫', [P((0, 480), (300, A), (0, 60)), P((270, 480), (570, A), (270, 60))])
    syn('∝', [C((430, 400), (350, 410), (260, 330), (160, 180), (90, 140), (35, 190), (25, A), (40, 350), (95, 400), (160, 370), (260, 210), (350, 130), (430, 145))])
    syn('∞', [('c', [(300 + 270 * math.cos(t), A + 150 * math.sin(2 * t)) for t in np.linspace(0, 2 * math.pi, 41)])]); syn('√', [P((0, 300), (70, 340), (190, 0), (330, 790), (600, 790))])
    syn('Σ', [P((440, 640), (440, 720), (20, 720), (250, 345), (20, -30), (440, -30), (440, 50))]); syn('∏', [P((0, 720), (540, 720)), P((100, 720), (100, -30)), P((440, 720), (440, -30))])
    INT = lambda dx=0: C((330 + dx, 800), (285 + dx, 860), (225 + dx, 840), (200 + dx, 740), (170 + dx, 340), (150 + dx, -60), (120 + dx, -170), (60 + dx, -190), (10 + dx, -130))
    syn('∫', [INT()]); syn('∮', [INT(), O(172, 330, 115, 115)])
    syn('∂', [C((80, 610), (170, 710), (280, 690), (340, 580), (360, 400), (330, 180), (250, 40), (150, 0), (60, 60), (40, 180), (90, 300), (190, 350), (290, 310), (350, 215))])
    syn('∇', [P((0, 700), (480, 700), (240, 0), (0, 700))]); syn('∆', [P((0, 0), (240, 700), (480, 0), (0, 0))])
    EL = lambda: C((400, 480), (230, 480), (90, 430), (30, A), (90, 110), (230, 60), (400, 60)); ELr = lambda: C((20, 480), (190, 480), (330, 430), (390, A), (330, 110), (190, 60), (20, 60))
    syn('∈', [EL(), P((40, A), (360, A))]); syn('∉', [EL(), P((40, A), (360, A)), P((110, -20), (330, 570))]); syn('∋', [ELr(), P((60, A), (380, A))])
    SUB = lambda dy=0: C((420, 470 + dy), (220, 470 + dy), (60, 400 + dy), (30, A + dy), (60, 140 + dy), (220, 70 + dy), (420, 70 + dy)); SUP = lambda dy=0: C((0, 470 + dy), (200, 470 + dy), (360, 400 + dy), (390, A + dy), (360, 140 + dy), (200, 70 + dy), (0, 70 + dy))
    syn('⊂', [SUB()]); syn('⊃', [SUP()]); syn('⊆', [SUB(80), bar(10, 10, 420)]); syn('⊇', [SUP(80), bar(10, 0, 410)])
    syn('∪', [C((30, 530), (30, 220), (80, 80), (220, 30), (360, 80), (410, 220), (410, 530))]); syn('∩', [C((30, 20), (30, 330), (80, 470), (220, 520), (360, 470), (410, 330), (410, 20))])
    syn('∅', [O(230, 340, 200, 250), P((60, -20), (400, 700))]); syn('∖', [P((0, 640), (260, -20))])
    syn('∀', [P((0, 700), (240, 0), (480, 700)), P((85, 470), (395, 470))]); syn('∃', [P((40, 700), (420, 700), (420, 0), (40, 0)), P((90, 350), (420, 350))])
    syn('¬', [P((20, 330), (400, 330), (400, 150))]); syn('∧', [P((20, 30), (220, 520), (420, 30))]); syn('∨', [P((20, 520), (220, 30), (420, 520))])
    syn('⊕', [O(250, A, 230, 230), bar(A, 60, 440), P((250, 80), (250, 460))], pen=0.85); syn('⊗', [O(250, A, 230, 230), P((110, 130), (390, 410)), P((110, 410), (390, 130))], pen=0.85)
    syn('⊥', [bar(20, 0, 440), P((220, 20), (220, 560))]); syn('∥', [P((60, -80), (60, 740)), P((250, -80), (250, 740))], jit=0.3); syn('∣', [P((60, -80), (60, 740))], jit=0.25)
    syn('∠', [P((440, 520), (20, 20), (470, 20))]); syn('∴', [DOT(220, 460), DOT(60, 110), DOT(380, 110)]); syn('∵', [DOT(220, 110), DOT(60, 460), DOT(380, 460)])
    syn('ℓ', [C((40, 200), (180, 330), (280, 560), (250, 720), (180, 740), (130, 640), (130, 200), (160, 50), (240, 0), (330, 70))])
    syn('ℝ', [P((40, 0), (40, 700)), P((140, 0), (140, 700)), C((40, 700), (300, 700), (410, 640), (430, 540), (400, 430), (300, 380), (40, 380)), P((250, 380), (450, 0))], pen=0.7)
    syn('ℕ', [P((40, 0), (40, 700), (140, 700)), P((40, 700), (420, 0), (520, 0)), P((140, 700), (520, 0)), P((520, 0), (520, 700))], pen=0.7)
    syn('ℤ', [P((30, 700), (470, 700)), P((470, 700), (140, 0)), P((370, 700), (40, 0)), P((40, 0), (490, 0))], pen=0.7)
    syn('→', [bar(A, 0, 580), P((400, 430), (580, A), (400, 110))]); syn('←', [bar(A, 0, 580), P((180, 430), (0, A), (180, 110))]); syn('↔', [bar(A, 0, 640), P((170, 420), (0, A), (170, 120)), P((470, 420), (640, A), (470, 120))])
    syn('↑', [P((180, -20), (180, 640)), P((20, 470), (180, 640), (340, 470))]); syn('↓', [P((180, 660), (180, 0)), P((20, 170), (180, 0), (340, 170))])
    syn('⇒', [bar(345, 0, 500), bar(195, 0, 500), P((400, 490), (620, A), (400, 50))]); syn('⇐', [bar(345, 120, 620), bar(195, 120, 620), P((220, 490), (0, A), (220, 50))])
    syn('⇔', [bar(345, 120, 560), bar(195, 120, 560), P((210, 490), (0, A), (210, 50)), P((470, 490), (680, A), (470, 50))])
    syn('⌊', [P((60, 780), (60, -150), (250, -150))]); syn('⌋', [P((190, 780), (190, -150), (0, -150))]); syn('⌈', [P((60, -150), (60, 780), (250, 780))]); syn('⌉', [P((190, -150), (190, 780), (0, 780))])
    syn('⟨', [P((230, 780), (40, 315), (230, -150))]); syn('⟩', [P((0, 780), (190, 315), (0, -150))])
    for m, st in {'̄': [P((-400, 600), (-90, 600))], '̂': [P((-400, 570), (-245, 690), (-90, 570))], '̃': [C((-400, 580), (-320, 650), (-245, 610), (-170, 570), (-90, 640))],
                  '́': [P((-290, 560), (-190, 690))], '̀': [P((-300, 690), (-200, 560))], '̈': [DOT(-320, 620, 0.9), DOT(-170, 620, 0.9)]}.items(): syn(m, st, keepx=True, adv=0, pen=0.8)
    for g, c in {'α': 'a', 'β': 'b', 'χ': 'c', 'δ': 'd', 'ε': 'e', 'φ': 'f', 'γ': 'g', 'η': 'h', 'ι': 'i', 'κ': 'k', 'λ': 'l', 'μ': 'm', 'ν': 'n', 'ο': 'o', 'π': 'p', 'ϑ': 'q', 'ρ': 'r', 'σ': 's', 'τ': 't', 'υ': 'u', 'ω': 'w', 'ξ': 'x', 'ψ': 'y', 'ζ': 'z',
                 'Γ': 'G', 'Δ': 'D', 'Θ': 'Q', 'Λ': 'L', 'Ξ': 'X', 'Π': 'P', 'Φ': 'F', 'Ψ': 'Y', 'Ω': 'W'}.items(): syn(g, hershey('greek', c))
    syn('θ', [O(170, 350, 150, 350), P((25, 350), (315, 350))]); syn('ς', [C((330, 430), (230, 480), (110, 440), (50, 320), (80, 180), (230, 90), (300, 20), (280, -90), (200, -130))])
    syn('ϵ', [C((330, 430), (200, 470), (70, 400), (40, 235), (70, 80), (200, 10), (330, 50)), P((50, 240), (260, 240))]); syn('ϕ', [O(190, 235, 170, 235), P((190, 700), (190, -220))])
    def compose(name, parts):
        if name in GL: return
        out = []
        for k in range(NV):
            img = np.zeros((CHt, CWd), np.uint8)
            for src, sc, x, y in parts:
                g = GL[src][(k * 3 + 1) % NV]; b = cv2.resize(g['bm'].astype(np.float32), None, fx=sc, fy=sc, interpolation=cv2.INTER_AREA) > 0.42
                if sc < 0.9: b = cv2.dilate(b.astype(np.uint8), cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)))
                py = max(0, min(int((OY - (y + g['bottom'] * sc)) * PX) - b.shape[0], CHt - b.shape[0])); px = int((x + OX) * PX)
                img[py:py + b.shape[0], px:px + b.shape[1]] |= b.astype(np.uint8)[:, :CWd - px]
            out.append(recsyn(img > 0))
        GL[name] = out
    for s, c in zip('⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿⁱ', '0123456789+−=()ni'): compose(s, [(c, 0.6, 0, 330 if c not in '+−=' else 300)])
    for s, c in zip('₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎ᵢₙₓₐₑₒⱼₖₘ', '0123456789+−=()inxaeojkm'): compose(s, [(c, 0.6, 0, -170)])
    for f, (n, d) in {'½': '12', '⅓': '13', '¼': '14', '¾': '34', '⅔': '23'}.items(): compose(f, [(n, 0.55, 0, 330), ('/', 0.95, 170, 0), (d, 0.55, 400, -20)])
    compose('∬', [('∫', 1, 0, 0), ('∫', 1, 190, 0)]); GL.setdefault('∑', GL['Σ'])
    return GL

ALIAS = {'Α': 'A', 'Β': 'B', 'Ε': 'E', 'Ζ': 'Z', 'Η': 'H', 'Ι': 'I', 'Κ': 'K', 'Μ': 'M', 'Ν': 'N', 'Ο': 'O', 'Ρ': 'P', 'Τ': 'T', 'Υ': 'Y', 'Χ': 'X', 'µ': 'μ', '∙': '·', '⋅': '·', '∼': '~', '‖': '∥', '‐': '-', '‑': '-', '⁄': '/', '∕': '/', '∶': ':', '⟶': '→'}

def write_font(GL, out, family):
    from fontTools.fontBuilder import FontBuilder
    from fontTools.pens.ttGlyphPen import TTGlyphPen
    from fontTools.agl import UV2AGL
    from fontTools.feaLib.builder import addOpenTypeFeaturesFromString
    gname = lambda ch: UV2AGL.get(ord(ch)) or 'uni%04X' % ord(ch)
    def outline(g):
        bm = np.pad(g['bm'].astype(np.uint8), 2); H = g['bm'].shape[0]; lsb = 40 if g['xoff'] is None else g['xoff']
        cs, _ = cv2.findContours(bm, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_NONE); pen = TTGlyphPen(None)
        for c in cs:
            if cv2.contourArea(c) < 25: continue
            c = cv2.approxPolyDP(c, 1.1, True).reshape(-1, 2)
            if len(c) >= 4: pen.qCurveTo(*[(int(round((x - 2) / PX + lsb)), int(round(g['bottom'] + (H - (y - 2)) / PX))) for x, y in c], None); pen.closePath()
        return pen.glyph(), (g['adv'] if g['adv'] is not None else int(g['bm'].shape[1] / PX + lsb + 38))
    order = ['.notdef', 'space'] + [f'space.alt{i}' for i in range(1, NV)]; glyphs = {}; adv = {'.notdef': 500}; cmap = {32: 'space', 0xA0: 'space'}
    p = TTGlyphPen(None); p.moveTo((60, 0)); p.lineTo((60, 700)); p.lineTo((440, 700)); p.lineTo((440, 0)); p.closePath(); glyphs['.notdef'] = p.glyph()
    for nm, w in zip(order[1:], (270, 300, 245, 285, 255, 310, 265)): glyphs[nm] = TTGlyphPen(None).glyph(); adv[nm] = w
    CL = [['space']] + [[f'space.alt{i}'] for i in range(1, NV)]
    for ch, vs in GL.items():
        base = gname(ch); cmap[ord(ch)] = base
        for k, g in enumerate(vs):
            nm = base if k == 0 else f'{base}.alt{k}'; glyphs[nm], adv[nm] = outline(g); order.append(nm); CL[k].append(nm)
    for a, b in ALIAS.items():
        if b in GL and ord(a) not in cmap: cmap[ord(a)] = gname(b)
    fb = FontBuilder(1000, isTTF=True); fb.setupGlyphOrder(order); fb.setupCharacterMap(cmap); fb.setupGlyf(glyphs)
    fb.setupHorizontalMetrics({k: (adv[k], fb.font['glyf'][k].xMin if fb.font['glyf'][k].numberOfContours else 0) for k in order}); fb.setupHorizontalHeader(ascent=950, descent=-300)
    fb.setupNameTable({'familyName': family, 'styleName': 'Regular', 'uniqueFontIdentifier': family + ' 1.0', 'fullName': family, 'psName': family.replace(' ', '') + '-Regular', 'version': 'Version 1.000'})
    fb.setupOS2(sTypoAscender=950, sTypoDescender=-300, sTypoLineGap=0, usWinAscent=1000, usWinDescent=420, sxHeight=XH, sCapHeight=CH); fb.setupPost()
    fea = "languagesystem DFLT dflt; languagesystem latn dflt; languagesystem grek dflt;\n" + ''.join(f"@v{i}=[{' '.join(cl)}];\n" for i, cl in enumerate(CL))
    fea += "feature calt { lookup rot {\n" + ''.join(f"  sub @v{i} @v0' by @v{i+1};\n" for i in range(NV - 1)) + "} rot; } calt;\n"
    addOpenTypeFeaturesFromString(fb.font, fea); fb.save(out); print('wrote', out, '-', len(GL), 'characters x', NV, 'variants')

def finish(samples, out):
    family = os.path.splitext(os.path.basename(out))[0].replace('-', ' ').replace('_', ' ')
    real = {c: len(v) for c, v in samples.items() if v}
    write_font(build_glyphs(samples), out, family)
    need = [c for c in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,?!'()-" if c not in real]
    json.dump(dict(from_sample=real, drawn_not_from_sample=need), open(os.path.splitext(out)[0] + '-report.json', 'w'), ensure_ascii=False, indent=1)
    print('REAL samples per character:', ' '.join(f'{c}:{n}' for c, n in sorted(real.items())))
    print('DRAWN (not in sample) letters/digits/basic punctuation:', ''.join(need) or 'none')

if __name__ == '__main__':
    a = sys.argv[1:]
    if not a or a[0] not in ('template', 'from-template', 'segment', 'from-labels'): sys.exit(__doc__)
    if a[0] == 'template': make_template(a[1])
    elif a[0] == 'segment': segment(a[1], a[2])
    elif a[0] == 'from-template':
        S = {}
        for path in a[2:]:
            page, d = read_template(path); print(path, '-> template page', page + 1, ',', len(d), 'characters found')
            for ch, v in d.items(): S.setdefault(ch, []).extend(v)
        finish(S, a[1])
    else:
        S = {}
        for wd in a[2:]:
            for ch, v in read_labels(wd).items(): S.setdefault(ch, []).extend(v)
        finish(S, a[1])
```

<!-- file: hw_doc.py -->
```python
#!/usr/bin/env python3
"""hw_doc.py - write a document out by hand with a font made by hw_font.py.
  hw_doc.py FONT.ttf SPEC.json OUT.pdf
  hw_doc.py --preview FONT.ttf OUT.pdf        specimen page for checking a new font
SPEC.json = {"page": {...}, "style": {...}, "blocks": [...]}   (every key optional except blocks)
 page : size "letter"|"a4" · columns 1 · margin 36 · font_size 13 or "fit" · pages 1 (target page count when font_size is "fit")
 style: tool "pencil"|"pen"|"marker" · ink "#1b2a5c" (pen colour) · smudges false · divider false (dotted line between columns)
        ruled false (notebook lines) · jitter 1.0 (0 = rigid, 1.5 = messy) · dpi 450 (pencil only) · darkness 1.0 (pencil only)
 blocks: {"h1": text} page title · {"h2": text} underlined section · {"h3": text} plain sub-heading · {"p": text} · {"note": text} smaller
        {"list": [text,..], "ordered": false} · {"table": [[cell,..],..], "widths": [fractions], "header": true, "box": false}
        {"space": lines} · {"break": "column"|"page"}
 text markup: _{subscript} ^{superscript}, "\\n" forces a new line, x̄ p̂ etc. via combining marks, any character the font has.
 a table row with fewer cells than columns lets its last cell span the remaining columns.
Needs: reportlab fonttools numpy opencv-python-headless pillow, and for pencil pdftoppm (poppler-utils) or pypdfium2"""
import sys, os, math, random, json, tempfile, subprocess, unicodedata, shutil
from reportlab.pdfgen import canvas
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont as RLFont
from reportlab.lib.colors import HexColor
from fontTools.ttLib import TTFont
COMB = '̄̂̃́̀̈'; SS = 0.68; SIZES = {'letter': (612, 792), 'a4': (595.28, 841.89)}

class Hand:
    def __init__(self, ttf):
        t = TTFont(ttf); order = set(t.getGlyphOrder()); self.nv = 1 + max([int(g.rsplit('.alt', 1)[1]) for g in order if '.alt' in g] or [0])
        self.tmp = tempfile.mkdtemp(); self.W = []; self.id = 'HW%d_' % (abs(hash(os.path.abspath(ttf))) % 99999)
        for k in range(self.nv):
            t = TTFont(ttf)
            for tb in t['cmap'].tables:
                for cp, g in list(tb.cmap.items()):
                    if k and f'{g}.alt{k}' in order: tb.cmap[cp] = f'{g}.alt{k}'
            if 'GSUB' in t: del t['GSUB']
            for n in t['name'].names:
                if n.nameID in (1, 3, 4, 6): n.string = f'{self.id}{k}'
            p = f'{self.tmp}/v{k}.ttf'; t.save(p); pdfmetrics.registerFont(RLFont(f'{self.id}{k}', p))
            cm = t.getBestCmap(); hm = t['hmtx']; self.W.append({chr(cp): hm[g][0] / 1000 for cp, g in cm.items()})
        self.missing = set()
    def has(self, ch): return ch in self.W[0]

class R:   # one rendering run
    def __init__(s, hand, spec):
        s.h = hand; pg = spec.get('page', {}); st = spec.get('style', {}); s.blocks = spec['blocks']
        s.PW, s.PH = SIZES.get(str(pg.get('size', 'letter')).lower(), SIZES['letter']); s.M = pg.get('margin', 36); s.ncol = max(1, int(pg.get('columns', 1)))
        s.GAP = 14; s.CW = (s.PW - 2 * s.M - (s.ncol - 1) * s.GAP) / s.ncol; s.fs_req = pg.get('font_size', 13); s.pages_req = int(pg.get('pages', 1))
        s.tool = st.get('tool', 'pencil'); s.J = float(st.get('jitter', 1.0)); s.ruled = bool(st.get('ruled')); s.divider = bool(st.get('divider')); s.smudges = bool(st.get('smudges'))
        s.dpi = int(st.get('dpi', 450)); s.dark = float(st.get('darkness', 1.0))
        s.ink = HexColor('#000000') if s.tool == 'pencil' else HexColor(st.get('ink', '#1b2a5c' if s.tool == 'pen' else '#121212'))
    # ---- text
    def parse(s, txt):
        out = []; mode = 0; i = 0; txt = str(txt).replace('\t', ' ').replace('**', '')
        while i < len(txt):
            if txt.startswith('_{', i): mode = -1; i += 2; continue
            if txt.startswith('^{', i): mode = 1; i += 2; continue
            if txt[i] == '}' and mode: mode = 0; i += 1; continue
            ch = txt[i]; i += 1
            if ch == '\n': out.append(dict(ch='\n', mode=0, k=0, acc=None)); continue
            if ch in '²³' and not mode: out.append(dict(ch={'²': '2', '³': '3'}[ch], mode=1, k=s.n % s.h.nv, acc=None)); s.n += 1; continue
            seq = ch
            if not s.h.has(ch) or unicodedata.category(ch)[0] == 'L':
                d = unicodedata.normalize('NFD', ch)
                if len(d) > 1 and s.h.has(d[0]) and all(m in COMB and s.h.has(m) for m in d[1:]): seq = d
                elif len(d) > 1 and s.h.has(d[0]) and not s.h.has(ch): s.h.missing.add(ch); seq = d[0]
            for ch in seq:
                if ch in COMB and out and out[-1]['ch'] != '\n': out[-1]['acc'] = (ch, s.n % s.h.nv); continue
                if not s.h.has(ch): s.h.missing.add(ch); ch = '?'
                out.append(dict(ch=ch, mode=mode, k=s.n % s.h.nv, acc=None)); s.n += 1
        return out
    def iw(s, it, fs): return s.h.W[it['k']][it['ch']] * fs * (SS if it['mode'] else 1)
    def wrap(s, items, fs, width):
        width *= 0.985; sp = 0.27 * fs; lines = []; cur = []; cw = 0; words = [[]]
        for it in items:
            if it['ch'] == '\n': words += [None, []]
            elif it['ch'] == ' ' and it['mode'] == 0: words.append([])
            else: words[-1].append(it)
        for w in words:
            if w is None: lines.append(cur); cur = []; cw = 0; continue
            if not w: continue
            ww = sum(s.iw(it, fs) for it in w)
            if cur and cw + sp + ww > width: lines.append(cur); cur = []; cw = 0
            if ww > width:
                s.hard += 1
                for it in w:
                    x = s.iw(it, fs)
                    if cur and cw + x > width: lines.append(cur); cur = []; cw = 0
                    cur.append(it); cw += x
                cur.append(None); cw += sp; continue
            if cur: cur.append(None); cw += sp
            cur += w; cw += ww
        if cur: lines.append(cur)
        return lines or [[]]
    def words(s, items):
        out = [[]]
        for it in items:
            if it['ch'] in ' \n' and not it['mode']: out.append([])
            else: out[-1].append(it)
        return [w for w in out if w]
    def natural(s, items, fs): return sum(0.27 * fs if (it['ch'] == ' ' and not it['mode']) else s.iw(it, fs) for it in items if it['ch'] != '\n')
    def text(s, items, x, y, fs):
        """draw one line with its baseline at y; returns end x"""
        c = s.c; RJ = s.RJ; J = s.J; sp = 0.27 * fs
        x += RJ.uniform(-0.05, 0.38) * fs * J; y += RJ.gauss(0, 0.045 * fs) * J
        slope = RJ.gauss(0, 0.0075) * J; lsz = 1 + RJ.gauss(0, 0.025) * J; wy = RJ.gauss(0, 0.02 * fs) * J; x0 = x
        for it in items:
            if it is None: x += sp * (1 + (RJ.uniform(0.75, 1.4) - 1) * J); wy = wy * 0.6 + RJ.gauss(0, 0.03 * fs) * J; continue
            wy = wy * 0.85 + RJ.gauss(0, 0.016 * fs) * J
            size = fs * (SS if it['mode'] else 1) * lsz * (1 + RJ.gauss(0, 0.035) * J); rise = {0: 0, 1: 0.36 * fs, -1: -0.2 * fs}[it['mode']]
            yy = y + rise + wy + slope * (x - x0) + RJ.gauss(0, 0.012 * fs) * J
            if c:
                c.saveState(); c.translate(x, yy); c.rotate(RJ.gauss(0, 2.2) * J); c.setFont(f"{s.h.id}{it['k']}", size); c.setFillColor(s.ink)
                if s.tool == 'pen': c.setFillAlpha(RJ.uniform(0.86, 1.0))
                if s.tool == 'marker': c.setStrokeColor(s.ink); c.setLineWidth(size * 0.03); c.drawString(0, 0, it['ch'], mode=2)
                else: c.drawString(0, 0, it['ch'])
                c.restoreState()
            w = s.h.W[it['k']][it['ch']] * size
            if it['acc'] and c:
                a, k = it['acc']; up = 0 if it['ch'] in 'xpacemnorsuvwz' else 0.235 * size
                c.setFillColor(s.ink); c.setFont(f'{s.h.id}{k}', size); c.drawString(x + w / 2 + 0.245 * size, yy + up, a)
            x += w + RJ.gauss(0, 0.012 * fs) * J
        return x
    # ---- hand-drawn strokes
    def wob(s, x0, y0, x1, y1, amp, over=1.2):
        r = s.rnd; L = math.hypot(x1 - x0, y1 - y0)
        if L < 1e-6: return [(x0, y0)]
        ux, uy = (x1 - x0) / L, (y1 - y0) / L; nx, ny = -uy, ux; a = r.uniform(0, over); b = r.uniform(0, over)
        x0 -= ux * a; y0 -= uy * a; L += a + b; n = max(2, int(L / 14)); offs = [r.gauss(0, amp)]
        for i in range(n): offs.append(offs[-1] * 0.55 + r.gauss(0, amp * 0.8))
        tilt = r.uniform(-1, 1) * min(1.0, L / 60) * s.J
        return [(x0 + ux * L * i / n + nx * (offs[i] + tilt * (i / n - 0.5)), y0 + uy * L * i / n + ny * (offs[i] + tilt * (i / n - 0.5))) for i in range(n + 1)]
    def line(s, x0, y0, x1, y1, w=0.75, amp=0.42):
        c = s.c; r = s.rnd
        if not c: return
        base = s.wob(x0, y0, x1, y1, amp * s.J); L = math.hypot(x1 - x0, y1 - y0) or 1; nx, ny = -(y1 - y0) / L, (x1 - x0) / L
        c.setStrokeColor(s.ink); c.setLineCap(1); c.setLineJoin(1)
        if s.tool != 'pencil':                                   # pen / marker: one continuous stroke
            c.setStrokeAlpha(1); c.setLineWidth(w * r.uniform(0.8, 1.15) * (2.1 if s.tool == 'marker' else 0.85)); p = c.beginPath(); p.moveTo(*base[0])
            for q in base[1:]: p.lineTo(*q)
            c.drawPath(p, stroke=1, fill=0); return
        w *= min(1.9, max(0.5, r.lognormvariate(0, 0.33))); tw = r.uniform(0.7, 1.3); tph = r.uniform(0, 6.28); tfr = r.uniform(0.02, 0.07)
        for ps in range(4):                                      # pencil: overlapping passes with changing pressure and width
            off = r.gauss(0, 0.16 * w / 0.75); pts = [(px + nx * (off + r.gauss(0, 0.05)), py + ny * (off + r.gauss(0, 0.05))) for px, py in base]; d = []
            for a, b in zip(pts[:-1], pts[1:]):
                n = max(1, int(math.hypot(b[0] - a[0], b[1] - a[1]) / 3)); d += [(a[0] + (b[0] - a[0]) * t / n, a[1] + (b[1] - a[1]) * t / n) for t in range(n)]
            d.append(pts[-1]); k = 0; press = r.uniform(0.3, 0.6); nd = max(1, len(d) - 1)
            while k < len(d) - 1:
                e = min(len(d) - 1, k + r.randint(1, 4)); press = min(0.72, max(0.14, press + r.gauss(0, 0.13)))
                if r.random() < 0.06: k = e; continue
                tw = min(1.7, max(0.5, tw + r.gauss(0, 0.10))); taper = 0.75 + 0.25 * math.sin(math.pi * min(1, k / nd))
                c.setStrokeAlpha(min(0.8, press * (0.8 + 0.25 * tw))); c.setLineWidth(w * r.uniform(0.45, 0.75) * tw * taper * (1 + 0.25 * math.sin(tph + k * 3 * tfr)))
                p = c.beginPath(); p.moveTo(*d[k])
                for q in d[k + 1:e + 1]: p.lineTo(*q)
                c.drawPath(p, stroke=1, fill=0); k = e
        c.setStrokeAlpha(1)
    def dashes(s, x0, x1, y):
        xx = x0
        while xx < x1 - 4: s.line(xx, y, min(xx + 7, x1), y, 0.8); xx += 11.5
    # ---- flow
    def start_page(s):
        s.col = 0; s.top = s.PH - s.M; s.y = s.top
        if s.c and s.ruled and s.tool != 'pencil':
            s.c.setStrokeColor(HexColor('#a9c4e6')); s.c.setLineWidth(0.5)
            for yy in s.rules(): s.c.line(0, yy, s.PW, yy)
    def end_page(s):
        if s.c and s.divider and s.ncol > 1:
            for j in range(1, s.ncol):
                xm = s.M + j * (s.CW + s.GAP) - s.GAP / 2; yy = s.top; dr = 0
                while yy > s.M + 2:
                    dr = dr * 0.8 + s.rnd.gauss(0, 0.12); L = s.rnd.uniform(0.9, 1.7); s.line(xm + dr, yy, xm + dr + s.rnd.gauss(0, 0.1), yy - L, 1.15, 0.05); yy -= L + s.rnd.uniform(3.0, 4.2)
        if s.c: s.c.showPage()
    def rules(s):
        out = []; yy = s.PH - s.M - s.lead * 0.8
        while yy > s.M * 0.6: out.append(yy); yy -= s.lead
        return out
    def newcol(s, page=False):
        s.col += 1
        if page or s.col >= s.ncol:
            s.end_page(); s.page += 1; s.start_page()
        s.y = s.top
    def need(s, h):
        if s.y - h < s.M - 0.5 and s.y < s.top - 0.5: s.newcol(); return True
        return False
    def X(s): return s.M + s.col * (s.CW + s.GAP)
    def Q(s, h): return math.ceil(h / s.lead - 0.02) * s.lead if s.ruled else h
    def G(s, h): return 0 if s.ruled else h
    def para(s, items, fs, indent=0, first_prefix=None):
        lead = s.lead if s.ruled else max(s.lead * fs / s.fs, fs * 1.17)
        for i, l in enumerate(s.wrap(items, fs, s.CW - indent)):
            s.need(lead)
            if i == 0 and first_prefix: s.text(first_prefix, s.X(), s.y - lead * 0.735, fs)
            s.text(l, s.X() + indent, s.y - lead * 0.735, fs); s.y -= lead
    def table(s, b):
        fs = s.fs; lead = s.lead; padx = fs * 0.33; pady = s.G(fs * 0.2); rows = [[s.parse(cell) for cell in r] for r in b['table']]; nc = max(len(r) for r in rows)
        fr = b.get('widths')
        if not fr or len(fr) != nc:
            full = [r for r in rows if len(r) == nc] or rows; word = lambda cell: max([s.natural(w, fs) for w in s.words(cell)] or [fs])
            lo = [max(word(r[j]) for r in full if j < len(r)) + 2 * padx + fs * 0.6 for j in range(nc)]; nat = [max(s.natural(r[j], fs) for r in full if j < len(r)) + 2 * padx + fs * 0.6 for j in range(nc)]
            spare = s.CW - sum(lo); want = [max(0.0, n - l) for n, l in zip(nat, lo)]
            fr = [l + (spare * w / sum(want) if spare > 0 and sum(want) > 0 else 0) for l, w in zip(lo, want)] if sum(nat) > s.CW else nat
        seg = []                                                   # rows drawn in the current column: (ytop, h, ncells)
        def flush():
            if not seg: return
            x = s.X(); xs = [x]
            for f in fr: xs.append(xs[-1] + s.CW * f / sum(fr))
            ys = [seg[0][0]] + [t - h for t, h, *_ in seg]
            for i, yy in enumerate(ys):
                if 0 < i < len(ys) - 1 or b.get('box'): s.line(x, yy, xs[-1], yy, 1.05 if (i == 1 and seg[0][3]) else 0.75)
            for j in range(len(xs)):
                edge = j in (0, len(xs) - 1); i = 0
                while i < len(seg):
                    ok = lambda r: (b.get('box') if edge else j < seg[r][2])
                    if ok(i):
                        e = i
                        while e + 1 < len(seg) and ok(e + 1): e += 1
                        s.line(xs[j], ys[i] - 0.5, xs[j], ys[e + 1] + 0.8, 0.9); i = e + 1
                    else: i += 1
            seg.clear()
        for ri, r in enumerate(rows):
            xs = [0]
            for f in fr: xs.append(xs[-1] + s.CW * f / sum(fr))
            cl = [s.wrap(cell, fs, (xs[j + 1] if j < len(r) - 1 else xs[-1]) - xs[j] - 2 * padx) for j, cell in enumerate(r)]
            h = max(len(l) for l in cl) * lead + 2 * pady
            if s.y - h < s.M - 0.5 and s.y < s.top - 0.5: flush(); s.newcol()
            for j, ls in enumerate(cl):
                for li, l in enumerate(ls): s.text(l, s.X() + xs[j] + padx, s.y - pady - lead * 0.735 - li * lead, fs)
            seg.append((s.y, h, len(r), ri == 0 and b.get('header', True))); s.y -= h
        flush(); s.y -= s.G(fs * 0.42)
    def layout(s, fs, c=None):
        s.c = c; s.fs = fs; s.lead = fs * 1.17; s.n = 3; s.RJ = random.Random(11); s.rnd = random.Random(5); s.page = 0; s.hard = 0
        s.start_page(); lead = s.lead
        for bi, b in enumerate(s.blocks):
            if 'h1' in b:
                z = fs * 1.42; h = s.Q(z * 1.5); s.need(h + lead); full = s.col == 0 and s.y > s.top - 0.5   # a title at the top of a page spans all columns
                for l in s.wrap(s.parse(b['h1']), z, (s.PW - 2 * s.M) if full else s.CW):
                    e = s.text(l, s.X(), s.y - h + z * 0.42, z); s.line(s.X(), s.y - h + z * 0.12, e, s.y - h + z * 0.12, 1.0); s.y -= h
                s.y -= s.G(fs * 0.3)
                if full: s.top = s.y
            elif 'h2' in b:
                z = fs * 1.22; h = s.Q(z * 1.5); attop = s.y > s.top - 0.5
                if not attop and s.need(h + fs * 0.45 + 3 * lead): attop = True
                if not attop:
                    if s.ruled: s.y -= lead
                    else: s.y -= fs * 0.25; s.dashes(s.X(), s.X() + s.CW, s.y); s.y -= fs * 0.2
                e = s.text(s.parse(b['h2']), s.X() + 1, s.y - h + z * 0.52, z); s.line(s.X(), s.y - h + z * 0.25, e + 1, s.y - h + z * 0.25, 1.0); s.y -= h
            elif 'h3' in b:
                h = s.Q(fs * 1.3); s.need(h + 2 * lead); s.text(s.parse(b['h3']), s.X(), s.y - h + fs * 0.35, fs * 1.04); s.y -= h
            elif 'p' in b: s.para(s.parse(b['p']), fs); s.y -= s.G(fs * 0.35)
            elif 'note' in b: s.para(s.parse(b['note']), fs * 0.92); s.y -= s.G(fs * 0.1)
            elif 'list'

[…] pełny plik w paczce .zip

Wersje

W bazie nie ma innych skilli o nazwie handwrite.

Migawka bazy z 13.11.2026 · wersja paczki f955e9734e9a (skrót SHA-256 całej paczki)

Pobrania w ostatnich 30 dniach: 0 · ranking

Zgłoś problem z tym skillem: szkodliwa treść, błędny opis albo prośba autora o usunięcie.

Podobne skille

  • 100handwriting-pdf
    Zamienia pismo użytkownika z obrazu lub PDF na font TrueType i profil, a potem składa dowolny tekst w tym piśmie do wydruku PDF.
  • 100draw-your-font
    Zamienia zdjęcie odręcznego pisma w gotową czcionkę TTF, WOFF lub WOFF2 do zainstalowania, z poprawkami kształtów liter.
  • 100handwrite-assignment
    Zamienia zadania akademickie i notatki na kompletne strony zeszytu w odręcznym stylu, renderowane przez narzędzie graficzne.
  • 100handwriting-homework
    Renderuje odpowiedzi do zadań jako strony w odręcznym piśmie użytkownika w 300 dpi i buduje jego font z próbek pisma.
  • 100handwriting-to-font
    Zamienia zdjęcie czyjegoś pisma w instalowalny font TTF, WOFF i CSS dla sieci, z szablonem do wydruku.
  • 100glmocr-handwriting
    Rozpoznaje pismo ręczne z obrazów przez API ZhiPu GLM-OCR: różne style pisma, języki i mieszane treści ręczne oraz drukowane.

Uwagi i zgłoszenia

Wiadomość trafi do Bartosza Sokolińskiego. Adres e-mail jest potrzebny tylko do odpowiedzi. Szczegóły w polityce prywatności.