|
2 | 2 | """ |
3 | 3 | Convert hand-drawn SVG glyphs into the base xkcd-script SFD font file. |
4 | 4 |
|
5 | | -Reads per-character SVG files produced by pt3_ppm_to_svg.py, scales and |
6 | | -positions each glyph to fit the EM, applies per-line scale corrections, and |
7 | | -saves the result as xkcd-script.sfd. |
| 5 | +Reads per-character SVG files produced by pt3_ppm_to_svg.py and additional |
| 6 | +comic-sourced SVGs produced by pt4_additional_sources.py, scales and positions |
| 7 | +each glyph to fit the EM, applies per-line scale corrections, and saves the |
| 8 | +result as xkcd-script.sfd. |
8 | 9 |
|
9 | | -Derived characters (diacriticals, aliases) are added in pt5_derived_chars.py. |
10 | | -Font-wide properties (kerning) are applied in pt6_font_properties.py. |
| 10 | +Derived characters (diacriticals, aliases) are added in pt6_derived_chars.py. |
| 11 | +Font-wide properties (kerning) are applied in pt7_font_properties.py. |
11 | 12 | """ |
12 | 13 | from __future__ import division |
13 | 14 | import base64 |
@@ -317,6 +318,240 @@ def charname(char): |
317 | 318 | c.width = 256 |
318 | 319 |
|
319 | 320 |
|
| 321 | +# --------------------------------------------------------------------------- |
| 322 | +# Glyphs imported from xkcd comic images |
| 323 | +# --------------------------------------------------------------------------- |
| 324 | + |
| 325 | +_COMIC_CHARS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), '../generated/additional_chars') |
| 326 | + |
| 327 | + |
| 328 | +def _scan_stroke_width(g, y_lo, y_hi, n=8): |
| 329 | + """Rough stroke-width estimate via horizontal line scanning. |
| 330 | +
|
| 331 | + Samples n evenly-spaced y values in [y_lo, y_hi]. At each y, finds all |
| 332 | + x-crossings of the contour edges (polyline approximation between consecutive |
| 333 | + points) and records the minimum gap between consecutive crossings. Returns |
| 334 | + the median of those per-y minimums — approximating the typical thinnest |
| 335 | + visible stroke in the scan band. |
| 336 | + """ |
| 337 | + results = [] |
| 338 | + for k in range(n): |
| 339 | + y = y_lo + (y_hi - y_lo) * (k + 0.5) / n |
| 340 | + xs = [] |
| 341 | + for contour in g.foreground: |
| 342 | + pts = list(contour) |
| 343 | + for i in range(len(pts)): |
| 344 | + p1, p2 = pts[i], pts[(i + 1) % len(pts)] |
| 345 | + if (p1.y - y) * (p2.y - y) < 0: |
| 346 | + t = (y - p1.y) / (p2.y - p1.y) |
| 347 | + xs.append(p1.x + t * (p2.x - p1.x)) |
| 348 | + xs.sort() |
| 349 | + gaps = [xs[j + 1] - xs[j] for j in range(len(xs) - 1)] |
| 350 | + if gaps: |
| 351 | + results.append(min(gaps)) |
| 352 | + if not results: |
| 353 | + return None |
| 354 | + results.sort() |
| 355 | + return results[len(results) // 2] |
| 356 | + |
| 357 | + |
| 358 | +def _import_comic_glyph(font, name, svg_path, target_top, weight_delta=0): |
| 359 | + """Import a pre-cleaned SVG (from pt4_additional_sources.py) and scale it |
| 360 | + so the top of the ink reaches target_top in font units, preserving the |
| 361 | + aspect ratio so any descender falls naturally below baseline. |
| 362 | +
|
| 363 | + weight_delta: if non-zero, apply changeWeight() after scaling. |
| 364 | + """ |
| 365 | + g = font.createChar(-1, f'_comic_{name}') |
| 366 | + g.clear() |
| 367 | + g.importOutlines(svg_path) |
| 368 | + |
| 369 | + bb = g.boundingBox() |
| 370 | + g.transform(psMat.scale(target_top / bb[3])) |
| 371 | + |
| 372 | + bb = g.boundingBox() |
| 373 | + g.transform(psMat.translate(-bb[0] + 20, 0)) |
| 374 | + |
| 375 | + if weight_delta: |
| 376 | + g.removeOverlap() |
| 377 | + g.changeWeight(weight_delta) |
| 378 | + |
| 379 | + g.correctDirection() |
| 380 | + # round() before removeOverlap() avoids a FontForge "endpoint intersection" |
| 381 | + # bug that fires when two spline curves meet exactly at a fractional- |
| 382 | + # coordinate point on the sweep line. |
| 383 | + g.round() |
| 384 | + g.removeOverlap() |
| 385 | + g.addExtrema() |
| 386 | + |
| 387 | + bb = g.boundingBox() |
| 388 | + g.width = int(round(bb[2] + 20)) |
| 389 | + return g |
| 390 | + |
| 391 | + |
| 392 | +# Greek letters vectorised by pt4 from xkcd comic images. |
| 393 | +# Each entry: (svg_name, unicode_cp, ref_char_for_height, baseline_snap) |
| 394 | +# baseline_snap=True → translate so bb[1]=0 (letters that sit on the baseline). |
| 395 | +# baseline_snap=False → leave at natural import coords; descender positioning |
| 396 | +# is handled separately via _GREEK_DESCENDER_FRAC. |
| 397 | +_GREEK = [ |
| 398 | + ('pi', 0x03C0, 'a', True), |
| 399 | + ('Delta', 0x0394, 'A', True), |
| 400 | + ('delta', 0x03B4, 'b', True), |
| 401 | + ('theta', 0x03B8, 'b', True), |
| 402 | + ('phi', 0x03C6, 'a', True), |
| 403 | + ('epsilon', 0x03B5, 'a', True), |
| 404 | + ('upsilon', 0x03C5, 'a', True), |
| 405 | + ('nu', 0x03BD, 'a', True), |
| 406 | + ('mu', 0x03BC, 'a', False), |
| 407 | + ('Sigma', 0x03A3, 'A', True), |
| 408 | + ('Pi', 0x03A0, 'A', True), |
| 409 | + ('zeta', 0x03B6, 'b', False), |
| 410 | + ('beta', 0x03B2, 'b', False), |
| 411 | + ('alpha', 0x03B1, 'a', True), |
| 412 | + ('Omega', 0x03A9, 'A', True), |
| 413 | + ('omega', 0x03C9, 'a', True), |
| 414 | + ('sigma', 0x03C3, 'a', True), |
| 415 | + ('xi', 0x03BE, 'b', False), |
| 416 | + ('gamma', 0x03B3, 'a', False), |
| 417 | + ('rho', 0x03C1, 'a', False), |
| 418 | + ('Xi', 0x039E, 'A', True), |
| 419 | + ('psi', 0x03C8, 'a', False), |
| 420 | + ('lambda', 0x03BB, 'b', True), |
| 421 | + ('tau', 0x03C4, 'a', True), |
| 422 | + ('varsigma', 0x03C2, 'a', True), |
| 423 | +] |
| 424 | + |
| 425 | +# Letters with genuine descenders: fraction of the crop height that is the |
| 426 | +# body (above baseline). Remaining fraction becomes the descender below y=0. |
| 427 | +# Estimated from pixel proportions in the 2× source image crop. |
| 428 | +_GREEK_DESCENDER_FRAC = { |
| 429 | + 'mu': 0.61, |
| 430 | + 'beta': 0.75, |
| 431 | + 'rho': 0.67, |
| 432 | +} |
| 433 | + |
| 434 | +# Measure target stroke width from 'l' — a clean vertical stroke with no |
| 435 | +# ambiguity from curves or bowls. |
| 436 | +_l_bb = font['l'].boundingBox() |
| 437 | +_target_stroke = _scan_stroke_width( |
| 438 | + font['l'], |
| 439 | + _l_bb[1] + (_l_bb[3] - _l_bb[1]) * 0.2, |
| 440 | + _l_bb[1] + (_l_bb[3] - _l_bb[1]) * 0.8, |
| 441 | +) |
| 442 | + |
| 443 | +# Glyphs dominated by horizontal strokes: the horizontal scan in |
| 444 | +# _scan_stroke_width measures bar *lengths* rather than stroke thickness, |
| 445 | +# producing a grossly over-estimated value and a large negative delta that |
| 446 | +# massively thins the letter. Skip stroke normalisation for these. |
| 447 | +_GREEK_NO_STROKE_NORM = {'epsilon', 'Xi', 'Sigma'} |
| 448 | + |
| 449 | +# Per-letter changeWeight nudge applied after all positioning and stroke |
| 450 | +# normalisation. Positive = thicker, negative = thinner. |
| 451 | +_GREEK_WEIGHT_NUDGE = { |
| 452 | + 'Sigma': 15, |
| 453 | + 'mu': 15, |
| 454 | + 'epsilon': 15, |
| 455 | + 'psi': -15, |
| 456 | + 'lambda': 10, |
| 457 | +} |
| 458 | + |
| 459 | +for _name, _cp, _ref, _snap in _GREEK: |
| 460 | + _svg = os.path.join(_COMIC_CHARS_DIR, f'{_name}.svg') |
| 461 | + _target_top = font[_ref].boundingBox()[3] |
| 462 | + _g = _import_comic_glyph(font, _name, _svg, target_top=_target_top) |
| 463 | + _desc_frac = _GREEK_DESCENDER_FRAC.get(_name) |
| 464 | + if _desc_frac is not None: |
| 465 | + # Seat the body in [0, target_top] and let the descender fall below y=0. |
| 466 | + # baseline_y is the font-unit y that corresponds to the baseline inside |
| 467 | + # the imported glyph. |
| 468 | + _bb = _g.boundingBox() |
| 469 | + _baseline_y = _bb[3] - _desc_frac * (_bb[3] - _bb[1]) |
| 470 | + _g.transform(psMat.translate(0, -_baseline_y)) |
| 471 | + _bb = _g.boundingBox() |
| 472 | + if _bb[3] > 0: |
| 473 | + _g.transform(psMat.scale(_target_top / _bb[3])) |
| 474 | + _g.width = int(round(_g.boundingBox()[2] + 20)) |
| 475 | + elif _snap: |
| 476 | + # Seat the glyph on the baseline: translate so bb[1]=0 then re-scale to |
| 477 | + # restore target_top. Handles both positive bb[1] (sub-baseline |
| 478 | + # whitespace in the source crop) and negative bb[1] (ink that slightly |
| 479 | + # undercuts the baseline). |
| 480 | + _bb = _g.boundingBox() |
| 481 | + if _bb[1] != 0: |
| 482 | + _g.transform(psMat.translate(0, -_bb[1])) |
| 483 | + _bb = _g.boundingBox() |
| 484 | + if _bb[3] > 0: |
| 485 | + _g.transform(psMat.scale(_target_top / _bb[3])) |
| 486 | + _g.width = int(round(_g.boundingBox()[2] + 20)) |
| 487 | + # Normalise stroke width AFTER all positioning so that snap/descender |
| 488 | + # re-scales do not alter the final stroke width. |
| 489 | + if _target_stroke is not None and _name not in _GREEK_NO_STROKE_NORM: |
| 490 | + _measured = _scan_stroke_width(_g, _target_top * 0.15, _target_top * 0.85) |
| 491 | + if _measured and _measured > 0: |
| 492 | + _delta = int(round(_target_stroke - _measured)) |
| 493 | + if abs(_delta) > 3: |
| 494 | + _g.correctDirection() |
| 495 | + _g.addExtrema() |
| 496 | + _g.removeOverlap() |
| 497 | + _g.changeWeight(_delta) |
| 498 | + _bb2 = _g.boundingBox() |
| 499 | + if _bb2[3] > 0: |
| 500 | + _g.transform(psMat.scale(_target_top / _bb2[3])) |
| 501 | + _bb2 = _g.boundingBox() |
| 502 | + _g.transform(psMat.translate(-_bb2[0] + 20, 0)) |
| 503 | + _g.width = int(round(_g.boundingBox()[2] + 20)) |
| 504 | + # Per-letter weight nudge (applied last so it overrides normalisation). |
| 505 | + _nudge = _GREEK_WEIGHT_NUDGE.get(_name) |
| 506 | + if _nudge: |
| 507 | + _g.correctDirection() |
| 508 | + _g.addExtrema() |
| 509 | + _g.removeOverlap() |
| 510 | + _g.changeWeight(_nudge) |
| 511 | + _bb2 = _g.boundingBox() |
| 512 | + if _bb2[3] > 0: |
| 513 | + _g.transform(psMat.scale(_target_top / _bb2[3])) |
| 514 | + _bb2 = _g.boundingBox() |
| 515 | + _g.transform(psMat.translate(-_bb2[0] + 20, 0)) |
| 516 | + _g.width = int(round(_g.boundingBox()[2] + 20)) |
| 517 | + _ch = font.createMappedChar(_cp) |
| 518 | + _ch.clear() |
| 519 | + for c in _g.foreground: |
| 520 | + _ch.foreground += c |
| 521 | + _ch.width = _g.width |
| 522 | + |
| 523 | + |
| 524 | +# ß (U+00DF) and ẞ (U+1E9E) — hand-drawn source from extras/eszett.png. |
| 525 | +# The same SVG is imported twice at different scales for the two case forms. |
| 526 | +_eszett_svg = os.path.join(_COMIC_CHARS_DIR, 'eszett.svg') |
| 527 | + |
| 528 | +_eszett_glyph = _import_comic_glyph( |
| 529 | + font, 'eszett', _eszett_svg, |
| 530 | + target_top=font['b'].boundingBox()[3] * 0.59, |
| 531 | + weight_delta=23) |
| 532 | +_bb = _eszett_glyph.boundingBox() |
| 533 | +if _bb[1] < 0: |
| 534 | + _eszett_glyph.transform(psMat.translate(0, -_bb[1])) |
| 535 | +_ch = font.createMappedChar(0x00DF) |
| 536 | +_ch.clear() |
| 537 | +for c in _eszett_glyph.foreground: |
| 538 | + _ch.foreground += c |
| 539 | +_ch.width = _eszett_glyph.width |
| 540 | + |
| 541 | +_cap_eszett_glyph = _import_comic_glyph( |
| 542 | + font, 'eszett_cap', _eszett_svg, |
| 543 | + target_top=font['B'].boundingBox()[3] * 0.72, |
| 544 | + weight_delta=19) |
| 545 | +_bb = _cap_eszett_glyph.boundingBox() |
| 546 | +if _bb[1] < 0: |
| 547 | + _cap_eszett_glyph.transform(psMat.translate(0, -_bb[1])) |
| 548 | +_ch = font.createMappedChar(0x1E9E) |
| 549 | +_ch.clear() |
| 550 | +for c in _cap_eszett_glyph.foreground: |
| 551 | + _ch.foreground += c |
| 552 | +_ch.width = _cap_eszett_glyph.width |
| 553 | + |
| 554 | + |
320 | 555 | # --------------------------------------------------------------------------- |
321 | 556 | # Save |
322 | 557 | # --------------------------------------------------------------------------- |
|
0 commit comments