#!/usr/bin/env python3 # Copyright (c) 2026 iispig.com # SPDX-License-Identifier: LGPL-2.1-or-later # # This program is free software; you can redistribute it and/or modify it under the terms of # the GNU Lesser General Public License as published by the Free Software Foundation; either # version 2.1 of the License, or (at your option) any later version. It is distributed in the # hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License # (LGPL-2.1.txt next to the published copy of this script) for more details. """Turn the bitmap strikes of Wine's tahoma.sfd / tahomabd.sfd into vector "pixel square" web fonts (WOFF2) for the iispig 2008 forum (Latin option (c)). .venv/bin/python tools/sfd-bitmap-to-ttf.py OUT_DIR [options] Input vendor/wine-tahoma/tahoma.sfd and tahomabd.sfd (--sfd-dir), FontForge SFD 3.0, pinned by SHA-256 and git blob id (--unpinned to accept other bytes). Only the bitmap part is read: "BitmapFont: " sections with "BDFChar: " records (the bitmap follows as FontForge's ASCII85 variant: 5 characters = 4 bytes, "z" = 4 zero bytes, rows top to bottom, each row padded to a whole byte) and "BDFRefChar: " references. The outline part is used only for glyph -> Unicode ("Encoding: "), for the outline advance of the space when a strike has no space glyph, and for the upstream licence notice and URL ("LangName: 1033", strings 13 and 14, FontForge's UTF-7). What is built (--build SPEC, repeatable; default DEFAULT_BUILDS = the recommended set) SPEC = @, e.g. r12@12 (regular 12 px strike for font-size:12px) or r15@14 (regular 15 px strike for font-size:14px). Bold: tahomabd.sfd is really bold only at 9-12 px. Its 13, 15 and 16 px strikes are the regular bitmaps (93 of 94 ASCII bitmaps identical; O is the regular O moved 1 px right) with wider advances, so b13/b15/b16 stop with exit status 3 unless --allow-copied-bold (used only to show the problem on the comparison page). * Every set pixel becomes a 100 x 100 unit square; adjacent squares are merged into one outline per connected shape (holes as counter-contours, diagonal touches split into separate contours). unitsPerEm = 100 x CSS px, so at the intended CSS font-size one bitmap pixel is exactly one CSS px: no size-adjust. At 2x the CSS size (24/28/32 px) it is exactly 2 px. * Advances are the strike's own pixel advances x 100. No kerning, no GSUB/GPOS (GDI drew bitmap strikes unkerned). * Vertical metrics (hhea, OS/2 typo + win, USE_TYPO_METRICS) are VMETRICS[CSS px]: the content area of the size-adjusted WenQuanYi face used at the same CSS size (12px: ascent 12 + descent 3; 14px: 12 + 3; 16px: 14 + 4), so the font can be the first available font of a line without moving the baseline. --metrics strike uses the strike's own ascent/descent instead (test only). * Character set: CHARSET (printable ASCII, space and no-break space, and the typographic marks the site uses) where the strike has the glyph, minus: - the curly quotes U+2018/2019/201C/201D at every size (--quotes wqy, the default): Wine's 13, 15 and 16 px opening quotes are broken upstream (a 1 px ‘, a 5 px “ against an 8 px ”), and narrow quotes at 12 px next to full-width ones at 14/16 px would not match, so WenQuanYi supplies all curly quotes, as in option (a). --quotes upstream keeps them as drawn (comparison only); --quotes mirror redraws a broken ‘/“ as the left-right mirror of the same strike's ’/” (how the 11 and 12 px strikes draw them). mirror CHANGES PIXELS: not for the site until the owner approves it. - STRIKE_EXCLUDE: « » ‹ › at 10 px (« sits 1 px lower than », ‹ is 4 rows, › 3). * --j-align stem (the default since 2026-10-18, approved by the owner; --j-align upstream keeps Wine's placement for comparison): the strikes put j's hook at x >= 0, so j's stem stands 1-2 px right of i's (J's right of I's) and the gap before every j is close to a word space ("pro ject"). The glyph is moved left as a whole until its stem is over i's (I's), the advance reduced by the same amount; pixels and right side bearing stay as drawn and the hook overhangs the origin, as j's does in the SFD outline. The shift is reduced while ink above the baseline would cross the origin (10 px J) or the moved glyph would share a pixel with any glyph before it (backslash and underscore are only reported, J_ALIGN_REPORT_ONLY). Recorded as a dated change in ID 10, the NOTICE and the manifest ("metricsModified"). The spaces are empty glyphs; a strike without a space (11 px and up), or with a 0 px no-break space (16 px), gets round(px x outline advance / 2048), which is what FreeType does for a glyph that is missing from a strike. * Names: "IISPIG Pixel Latin " Regular/Bold. Neither "Bitstream" nor "Vera" (Bitstream Vera licence) nor "Tahoma" (a Microsoft trademark) is in any name ID 1-6. ID 0 keeps the upstream copyright line and adds the Vera copyright/trademark notice; ID 9 "Larry Snyder; the Wine project authors" (the SFD names no author for the strikes); ID 10 says what was changed and when; ID 13 is the upstream LGPL notice verbatim (LangName 1033 string 13 of the SFD) plus a note on Bitstream Vera, the licence URL and the upstream LGPL URL (string 14) (--embed-vera-licence also appends the whole Bitstream Vera licence, its first line as the plain notice, about +1 KB per font); ID 14 the licence URL (--licence-url). * Checks (exit status 3 on failure): every output glyph is rasterised back (non-zero winding at pixel centres) and must equal the source bitmap (or the --quotes mirror drawing, or the --j-align position); all points on the 100-unit grid; only horizontal/vertical segments; no contour reuses a point; ink inside the vertical metrics; paired marks (PAIRS: ‘’ “” «» ‹› () [] {} <>) that are both in the font have the same pixel count, ink rows and advance (--allow-pair-mismatch to build anyway, comparison only). Ink that overhangs the advance (GDI style, e.g. bold T) is kept and listed in the manifest ("overhang"). Warning only (manifest "gapCheck"): for every pair of letters A-Z a-z, the empty columns above the baseline between the two inks; a pair whose gap is >= the word gap - 1 (n, the space the page draws, n) reads as a word break. Digits are not checked (tabular figures). Output OUT_DIR/pxl[-at]..woff2 (hash = SHA-256 of the WOFF2; older files of the same build are removed) OUT_DIR/pxlatin-manifest.json per font: file, bytes, family, metrics, advances (px) of every character as the page draws it with the recommended CSS ("advancePx": at 12/14/16 px the spaces are WenQuanYi's, WQY_SPACE_PX; the font's own space advances, drawn only under option B, are in "advancePxNotRendered"), ink boxes, synthesised glyphs, unicode-range, gap check, source hashes. --css FILE @font-face rules for exactly the RECOMMENDED faces that were built (PXL10, PXL12, PXL14 = the 15 px strike, PXL16; weight 400 only, no bold face), font URLs = --url-prefix + file name. The site build takes this file verbatim; comparison-only faces live in design/latin-c/fonts/pxlatin-test.css. --licence-dir DIR (default OUT_DIR/../licenses): LGPL-2.1.txt, Wine-LICENSE.txt, Bitstream-Vera-LICENSE.txt, PXLATIN-NOTICE.txt (dated modification notice), source/ (the SFD files the fonts were made from, and a SOURCE.txt written for the bundle: LGPL-2.1 section 4 asks for the source from the same place as the fonts; with --sources all, the default, every pinned SFD, so tahomabd.sfd too), this script and its requirements, a copy of the manifest and index.html (font links = --url-prefix + file name). A missing input is an error (exit 1). Only --no-licence skips the bundle (and with it the source copy): test builds. Dates (LGPL-2.1 section 2(b): "the date of any change") CONVERSION_RULES_DATE is when the rules above last changed; the build date is --date YYYY-MM-DD, else SOURCE_DATE_EPOCH, else today (warning). The same inputs and date give byte-identical files. """ import argparse import datetime import hashlib import html import json import os import re import shutil import sys import brotli import fontTools from fontTools.fontBuilder import FontBuilder from fontTools.misc.timeTools import timestampSinceEpoch from fontTools.pens.ttGlyphPen import TTGlyphPen from fontTools.ttLib import TTFont HERE = os.path.dirname(os.path.abspath(__file__)) SITE = os.path.dirname(HERE) SCRIPT_NAME = "sfd-bitmap-to-ttf.py" UNITS = 100 # font units per bitmap pixel VERSION = "1.000" CONVERSION_RULES_DATE = "2026-10-18" UPSTREAM = "https://gitlab.winehq.org/wine/wine/-/raw/master/fonts/" # the same bytes by content address (git blob id), independent of the moving master branch PINNED = "https://gitlab.winehq.org/api/v4/projects/wine%2Fwine/repository/blobs/{blob}/raw" # published layout (step 3): fonts in /fonts/latin/, this bundle in /fonts/latin/licenses/ (the WenQuanYi # bundle keeps /fonts/licenses/). Name ID 14 is baked into every font, so it changes the font hashes. DEFAULT_URL_PREFIX = "/fonts/latin/" DEFAULT_LICENCE_URL = "https://iispig.com/fonts/latin/licenses/" SOURCES = { "r": {"file": "tahoma.sfd", "style": "Regular", "weight": 400, "sha256": "0c1a3182a3a6b3d444113421121cbacf87322111aff245ee616109bd47479710", "gitBlob": "466f250496337045bbd574741c8698407dce8719"}, "b": {"file": "tahomabd.sfd", "style": "Bold", "weight": 700, "sha256": "90e42b98fc092034e6485a7f8f811195ca2ca148b6704e5d2df56a4c97dfca49", "gitBlob": "dacd3ae05c775091b81c238a6839fb3381fe28d4"}, } # the recommended set-up (design/latin-c/NOTES.md section 1): build -> CSS family. --css writes # exactly these faces (weight 400; bold text keeps the 400 face plus the 1px shadow, like the CJK). # Comparison builds (b12@12, r13@14, ...) are made by design/latin-c/run.py into fonts/test/. RECOMMENDED = {"r10@10": "PXL10", "r12@12": "PXL12", "r15@14": "PXL14", "r16@16": "PXL16"} DEFAULT_BUILDS = list(RECOMMENDED) # tahomabd.sfd's 13, 15 and 16 px strikes are the regular strikes' bitmaps (93 of 94 ASCII bitmaps # identical; O is the regular O moved 1px right) with bold advance widths: not a bold design. # build_font refuses a bold strike whose ASCII bitmaps are >= 90% identical to the regular strike # unless allow_copied_bold. COPIED_BOLD_LIMIT = 0.9 # (ascent, descent) in px per CSS size. 12/14/16: the size-adjusted WQY12/14/16 (WQY's # 100 units per px x 1200/300, 1200/300, 1400/400). 10: the 2008 forum style's .smfont 10px/15px line, # no WQY there; the strike's 8 + 2 plus one row above. VMETRICS = {10: (9, 2), 12: (12, 3), 14: (12, 3), 16: (14, 4)} SPACES = [0x0020, 0x00A0] EXTRA = [0x00A9, 0x00AB, 0x00B7, 0x00BB, 0x2013, 0x2014, 0x2018, 0x2019, 0x201C, 0x201D, 0x2026, 0x2039, 0x203A] CHARSET = SPACES + list(range(0x21, 0x7F)) + EXTRA QUOTES = [0x2018, 0x2019, 0x201C, 0x201D] QUOTE_PAIRS = [(0x2018, 0x2019), (0x201C, 0x201D)] # (opening, closing) # marks whose two halves must match: same pixel count, same ink rows, same advance PAIRS = QUOTE_PAIRS + [(0x00AB, 0x00BB), (0x2039, 0x203A), (0x28, 0x29), (0x5B, 0x5D), (0x7B, 0x7D), (0x3C, 0x3E)] # characters left out of a strike because the upstream pair does not match (they fall back to # the next font in the CSS stack; the 10px .smfont footer never uses them) STRIKE_EXCLUDE = {10: ([0x00AB, 0x00BB, 0x2039, 0x203A], "upstream 10 px: « (rows 0-4) sits 1 px lower than » (rows 1-5); ‹ is 4 rows, › 3")} QUOTES_WHY = ("Wine's 13/15/16 px opening quotes are broken upstream (‘ 1 px, “ 5 px against 4 and 8 for ’ ”); " "WenQuanYi supplies all curly quotes at every size, as in option (a)") # option A: the unicode-range of the 12/14/16px faces has no U+0020 / U+00A0, so the WenQuanYi # face after it stays the first available font and supplies the spaces WQY_SIZES = (12, 14, 16) # CSS sizes that have a size-adjusted WenQuanYi face after the pixel Latin face # the space and no-break space WenQuanYi draws at those sizes (tools/subset-wqy.py SET_ADVANCE: Arial's # widths; design/latin-c/run.py checks them against the widths Chrome lays out). Under option A these, # not this font's own spaces, are what the page shows, so the manifest's advancePx uses them. WQY_SPACE_PX = {12: {0x20: 3, 0xA0: 3}, 14: {0x20: 4, 0xA0: 4}, 16: {0x20: 4, 0xA0: 4}} # --j-align stem (default; approved by the owner 2026-10-18): Wine's strikes draw j with its hook at x >= 0, so # j's stem stands 1-2 px right of i's and the gap before every j is close to a word space # ("pro ject"). The SFD outline has i's stem at x 127-311 and j's at 140-324 (the hook overhangs # to -90). The fix moves the glyph origin so the stem lands in i's stem column (J's in I's) and # reduces the advance by the same amount: the right side bearing and every pixel stay as drawn, # the hook overhangs to the left. Largest shift such that no ink above the baseline crosses the # origin and the moved glyph shares no pixel with any glyph of the font placed before it, except # J_ALIGN_REPORT_ONLY: \ and _ run to the edge of their advance below the baseline, so any # overhang meets them (_ overlaps j in the outline as well); those overlaps are only reported. J_ALIGN = ((0x6A, 0x69), (0x4A, 0x49)) # (glyph, reference whose stem column it takes) J_ALIGN_REPORT_ONLY = (0x5C, 0x5F) LETTERS = list(range(0x41, 0x5B)) + list(range(0x61, 0x7B)) LICENCE_FILES = [ # (published name, vendor-relative source, description) ("LGPL-2.1.txt", "licenses/COPYING.LIB.txt", "GNU Lesser General Public License 2.1 (Wine's COPYING.LIB)"), ("Wine-LICENSE.txt", "licenses/Wine-LICENSE.txt", "Wine's LICENSE: LGPL-2.1-or-later"), ("Bitstream-Vera-LICENSE.txt", "licenses/Bitstream-Vera-LICENSE.txt", "Bitstream Vera Fonts copyright, trademark and permission notice (text from SPDX license-list-data)"), ] VERA_SHA256 = "5b3b4d3cef4029bda987a71f4bfb6087e40c5e91a2cb853fe7f6ccb78d9d8e38" VERA_SOURCE = "https://raw.githubusercontent.com/spdx/license-list-data/main/text/Bitstream-Vera.txt" VERA_NOTICE = ("Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. " "Bitstream Vera is a trademark of Bitstream, Inc.") J_ALIGN_DATE = "2026-10-18" # when the --j-align rule was written (dated change, LGPL-2.1 2(b)) KEYWORDS = ("BDFChar:", "BDFRefChar:", "EndBitmapFont", "BitmapFont:", "BDFStartProperties:", "BDFEndProperties", "Resolution:", "EndBitmapFonts", "EndSplineFont") class Fail(Exception): pass # ---------------------------------------------------------------- SFD reading def dec85(s): """FontForge's SFD bitmap encoding: ASCII85, 'z' = four zero bytes.""" s = "".join(s.split()) out, i = bytearray(), 0 while i < len(s): if s[i] == "z": out += b"\0\0\0\0" i += 1 continue grp = s[i:i + 5] i += 5 n = len(grp) v = 0 for c in grp + "u" * (5 - n): if not "!" <= c <= "u": raise Fail(f"bad ASCII85 character {c!r}") v = v * 85 + ord(c) - 33 out += v.to_bytes(4, "big")[:4 if n == 5 else n - 1] return bytes(out) B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" def ff_utf7(s): """A FontForge SFD string (UTF-7 as FontForge writes it: each base64 run packs UTF-16BE into 3-byte groups and pads the last group with zero bytes, which are not characters).""" out, i = [], 0 while i < len(s): if s[i] != "+": out.append(s[i]) i += 1 continue j = i + 1 while j < len(s) and s[j] in B64: j += 1 run = s[i + 1:j] if j < len(s) and s[j] == "-": j += 1 if not run: out.append("+") # "+-" is a literal plus else: bits = "".join(f"{B64.index(c):06b}" for c in run) data = bytes(int(bits[k:k + 8], 2) for k in range(0, len(bits) - 7, 8)) for k in range(0, len(data) - 1, 2): u = data[k] << 8 | data[k + 1] if u: out.append(chr(u)) i = j return "".join(out) def parse_sfd(path): with open(path, "rb") as fh: lines = fh.read().decode("utf-8", errors="replace").split("\n") head, glyphs, strikes = {}, {}, {} i, n = 0, len(lines) in_ttf = False cur_glyph = None while i < n: ln = lines[i].rstrip("\r") i += 1 if in_ttf: in_ttf = not ln.startswith("EndTtf") continue if ln.startswith("TtfTable:"): in_ttf = True continue if not glyphs and cur_glyph is None and ":" in ln and not ln.startswith(" "): key, _, val = ln.partition(":") if key in ("FontName", "FullName", "FamilyName", "Weight", "Copyright", "Version", "Ascent", "Descent") and key not in head: head[key] = val.strip() if ln.startswith("LangName: 1033 ") and "LangName1033" not in head: # the English (US) name strings, in name ID order (FontForge's UTF-7 inside "...") head["LangName1033"] = [ff_utf7(v) for v in re.findall(r'"([^"]*)"', ln)] if ln.startswith("StartChar:"): cur_glyph = {"name": ln.split(":", 1)[1].strip()} continue if cur_glyph is not None: if ln.startswith("Encoding:"): enc, uni, gid = (int(v) for v in ln.split()[1:4]) cur_glyph.update(enc=enc, unicode=uni, gid=gid) elif ln.startswith("Width:"): cur_glyph["width"] = int(ln.split()[1]) elif ln.startswith("EndChar"): glyphs[cur_glyph["gid"]] = cur_glyph cur_glyph = None continue if ln.startswith("BitmapFont:"): f = ln.split() px, count, asc, desc, depth = (int(v) for v in f[1:6]) if depth != 1: raise Fail(f"{path}: strike {px} has depth {depth}, only 1-bit strikes are supported") strike = {"px": px, "glyphCount": count, "ascent": asc, "descent": desc, "chars": {}, "refs": []} strikes[px] = strike while i < n: ln = lines[i].rstrip("\r") i += 1 if ln.startswith("EndBitmapFont"): break if ln.startswith("BDFStartProperties:"): while i < n and not lines[i].startswith("BDFEndProperties"): i += 1 i += 1 continue if ln.startswith("BDFRefChar:"): f = ln.split() strike["refs"].append(tuple(int(v) for v in f[1:5])) continue if ln.startswith("BDFChar:"): f = [int(v) for v in ln.split()[1:8]] gid, enc, adv, xmin, xmax, ymin, ymax = f data = [] while i < n and not lines[i].startswith(KEYWORDS): data.append(lines[i].strip()) i += 1 w, h = xmax - xmin + 1, ymax - ymin + 1 bpr = (w + 7) // 8 raw = dec85("".join(data)) if len(raw) < bpr * h: raise Fail(f"{path}: strike {px} glyph {gid}: {len(raw)} bytes, need {bpr * h}") pix = set() for r in range(h): row = raw[r * bpr:(r + 1) * bpr] for c in range(w): if row[c >> 3] & (0x80 >> (c & 7)): pix.add((xmin + c, ymax - r)) strike["chars"][gid] = {"gid": gid, "enc": enc, "advance": adv, "pixels": pix} continue if ln.strip() and not ln.startswith("Resolution:"): raise Fail(f"{path}: unexpected line in strike {px}: {ln[:60]!r}") for strike in strikes.values(): for gid, ref, xo, yo in strike["refs"]: if gid in strike["chars"] and ref in strike["chars"]: strike["chars"][gid]["pixels"] |= {(x + xo, y + yo) for x, y in strike["chars"][ref]["pixels"]} em = int(head["Ascent"]) + int(head["Descent"]) return {"head": head, "em": em, "glyphs": glyphs, "strikes": strikes} # ---------------------------------------------------------------- outlines def trace(pixels): """Outline of the union of unit squares (x, y) .. (x+1, y+1), y up. Each square contributes its four edges clockwise; an edge shared by two set squares appears once in each direction and cancels. The rest form closed loops: outer boundaries clockwise, holes counter-clockwise (TrueType's convention). Where two squares touch only at a corner the walk takes the rightmost turn (keeps to the same square), and a loop that still meets itself at a corner is split there, so each contour is a simple polygon. Collinear points are dropped. """ edges = set() for (x, y) in pixels: for e in (((x, y), (x, y + 1)), ((x, y + 1), (x + 1, y + 1)), ((x + 1, y + 1), (x + 1, y)), ((x + 1, y), (x, y))): r = (e[1], e[0]) if r in edges: edges.remove(r) else: edges.add(e) out = {} for a, b in edges: out.setdefault(a, []).append(b) contours = [] while edges: # the smallest remaining start point never has a second loop through it # (that would need a square in the column to its left), so the walk ends there a, b = min(edges) edges.remove((a, b)) out[a].remove(b) loop, prev, cur = [a], a, b while cur != a: loop.append(cur) din = (cur[0] - prev[0], cur[1] - prev[1]) def rank(c, cur=cur, din=din): d = (c[0] - cur[0], c[1] - cur[1]) cross = din[0] * d[1] - din[1] * d[0] return 0 if cross < 0 else 1 if cross == 0 else 2 # right, straight, left if not out.get(cur): raise Fail("open contour") nxt = min(out[cur], key=rank) out[cur].remove(nxt) edges.remove((cur, nxt)) prev, cur = cur, nxt # a loop that passes a corner twice (a counter that touches the outside at one # corner, e.g. 13px B) is split there into two simple loops; the winding of each # part, and so the filled area, stays the same stack = [loop] while stack: lp = stack.pop() seen, cut = {}, None for k, v in enumerate(lp): if v in seen: cut = (seen[v], k) break seen[v] = k if cut: i, j = cut stack += [lp[i:j], lp[:i] + lp[j:]] continue # drop collinear points pts = [] m = len(lp) for k in range(m): p0, p1, p2 = lp[k - 1], lp[k], lp[(k + 1) % m] if (p1[0] - p0[0]) * (p2[1] - p1[1]) - (p1[1] - p0[1]) * (p2[0] - p1[0]) != 0: pts.append(p1) contours.append(pts) contours.sort() return contours def rasterise(glyf_glyph, glyf_table): """Set pixels of a glyf glyph (non-zero winding at pixel centres), in px.""" if glyf_glyph.numberOfContours == 0: return set() coords, ends, _ = glyf_glyph.getCoordinates(glyf_table) polys, start = [], 0 for e in ends: polys.append([tuple(coords[k]) for k in range(start, e + 1)]) start = e + 1 vedges = [] for poly in polys: for k in range(len(poly)): (x0, y0), (x1, y1) = poly[k], poly[(k + 1) % len(poly)] if x0 == x1 and y0 != y1: vedges.append((x0, y0, y1)) xs = [p[0] for poly in polys for p in poly] ys = [p[1] for poly in polys for p in poly] pix = set() for py in range(min(ys) // UNITS, max(ys) // UNITS): cy = py * UNITS + UNITS // 2 for px in range(min(xs) // UNITS, max(xs) // UNITS): cx = px * UNITS + UNITS // 2 wn = 0 for (x, y0, y1) in vedges: if x > cx: if y0 <= cy < y1: wn += 1 elif y1 <= cy < y0: wn -= 1 if wn: pix.add((px, py)) return pix # ---------------------------------------------------------------- building def parse_spec(spec): m = re.fullmatch(r"([rb])(\d+)@(\d+)", spec) if not m: raise Fail(f"bad --build {spec!r}; expected e.g. r12@12 or b13@14") return m.group(1), int(m.group(2)), int(m.group(3)) def family_name(strike): return f"IISPIG Pixel Latin {strike}" def file_stem(w, strike, css): return f"pxl{strike}{w}" + ("" if css == strike else f"-at{css}") def name_ok(s): return not re.search(r"bitstream|vera|tahoma", s, re.I) def unicode_map(sfd, strike): out = {} for gid, ch in strike["chars"].items(): g = sfd["glyphs"].get(gid) uni = g["unicode"] if g else ch["enc"] if uni >= 0: out[uni] = ch return out def urange(cps): """CSS unicode-range for a set of code points, consecutive runs merged.""" cps, out, i = sorted(cps), [], 0 while i < len(cps): j = i while j + 1 < len(cps) and cps[j + 1] == cps[j] + 1: j += 1 out.append(f"U+{cps[i]:04X}" + (f"-{cps[j]:04X}" if j > i else "")) i = j + 1 return ",".join(out) def pair_sig(ch): p = ch["pixels"] ys = [y for x, y in p] return {"px": len(p), "rows": [min(ys), max(ys) + 1] if p else None, "advancePx": ch["advance"]} def pair_mismatches(chars): """Paired marks that are both in chars but differ in pixel count, ink rows or advance.""" out = [] for a, b in PAIRS: if a in chars and b in chars: sa, sb = pair_sig(chars[a]), pair_sig(chars[b]) if sa != sb: out.append({"pair": chr(a) + chr(b), chr(a): sa, chr(b): sb}) return out def stem_col(pixels): """The column with the most ink at or above the baseline (leftmost on a tie).""" cnt = {} for x, y in pixels: if y >= 0: cnt[x] = cnt.get(x, 0) + 1 return max(cnt, key=lambda x: (cnt[x], -x)) if cnt else None def touches(a, b): """(shared pixels, 4-neighbour contacts) between two pixel sets.""" shared = len(a & b) contact = sum(1 for x, y in b if (x, y) not in a and any(n in a for n in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)))) return shared, contact def align_stem(chars, g, ref): """--j-align stem for one glyph: the shift (px to the left) that puts g's stem over ref's, reduced until nothing collides (see J_ALIGN). Pixels move as a whole; no pixel is added or removed.""" ch = chars[g] full = stem_col(ch["pixels"]) - stem_col(chars[ref]["pixels"]) rec = {"char": chr(g), "ref": chr(ref), "fullShiftPx": max(0, full), "shiftPx": 0, "advancePx": [ch["advance"], ch["advance"]], "blocked": []} for s in range(full, 0, -1): moved = {(x - s, y) for x, y in ch["pixels"]} if any(x < 0 for x, y in moved if y >= 0): rec["blocked"].append({"shiftPx": s, "why": "ink above the baseline would cross the origin"}) continue hit, soft, contact = [], [], [] for u, prev in sorted(chars.items()): if not prev["pixels"]: continue placed = {(x + prev["advance"], y) for x, y in moved} shared, cont = touches(prev["pixels"], placed) if shared: (soft if u in J_ALIGN_REPORT_ONLY else hit).append(f"{chr(u)}{chr(g)}:{shared}") elif cont: contact.append(chr(u) + chr(g)) if hit: rec["blocked"].append({"shiftPx": s, "why": "would share pixels with the glyph before it", "pairs": hit}) continue assert {(x + s, y) for x, y in moved} == ch["pixels"] rec.update(shiftPx=s, advancePx=[ch["advance"], ch["advance"] - s], pixels=moved, inkPx=[min(x for x, y in moved), max(x for x, y in moved) + 1], overlapsReportOnly=soft, newContacts=sorted(set(contact) - set(_contacts(chars, g, ch["pixels"])))) break return rec def _contacts(chars, g, pixels): out = [] for u, prev in chars.items(): if prev["pixels"] and touches(prev["pixels"], {(x + prev["advance"], y) for x, y in pixels})[1]: out.append(chr(u) + chr(g)) return out def gap_check(chars, css, own_space): """Above-baseline gap (empty columns between the ink of two letters placed one after the other) for every pair of letters A-Z a-z, against the word gap (n + space + n). Pairs whose gap is >= word gap - 1 read as a word break ("pro ject"). Digits are left out: Tahoma's figures are tabular, so 1 stands in a wide advance on purpose.""" letters = [u for u in LETTERS if u in chars and any(y >= 0 for x, y in chars[u]["pixels"])] box = {} for u in letters: xs = [x for x, y in chars[u]["pixels"] if y >= 0] box[u] = (min(xs), max(xs)) def gap(a, b): return chars[a]["advance"] + box[b][0] - box[a][1] - 1 if css in WQY_SPACE_PX: space, space_from = WQY_SPACE_PX[css][0x20], f"WenQuanYi {css}px (option A)" else: space, space_from = own_space, "this font" word = gap(ord("n"), ord("n")) + space thr = word - 1 pairs = {chr(a) + chr(b): gap(a, b) for a in letters for b in letters} vals = sorted(pairs.values()) flagged = {p: v for p, v in sorted(pairs.items()) if v >= thr} before, after = {}, {} for p in flagged: after.setdefault(p[0], []).append(p[1]) before.setdefault(p[1], []).append(p[0]) n = len(letters) summary = [f"before {c} {len(v)}/{n}" for c, v in sorted(before.items(), key=lambda kv: -len(kv[1])) if len(v) > 2] \ + [f"after {c} {len(v)}/{n}" for c, v in sorted(after.items(), key=lambda kv: -len(kv[1])) if len(v) > 2] lone = [p for p in flagged if len(before[p[1]]) <= 2 and len(after[p[0]]) <= 2] return {"letters": n, "spacePx": space, "spaceFrom": space_from, "wordGapPx": word, "thresholdPx": thr, "medianPx": vals[len(vals) // 2], "maxPx": vals[-1], "flaggedPairs": len(flagged), "summary": summary + lone, "flagged": flagged, "beforeMaxPx": {c: max(gap(a, ord(c)) for a in letters) for c in "jJ" if ord(c) in box}, "afterNPx": {c: gap(ord("n"), ord(c)) for c in "ijIJ" if ord(c) in box}} def copied_from(strike_px, sfd, other): """(identical, compared) ASCII glyph bitmaps between two SFDs' strikes of the same size.""" if other is None or strike_px not in other["strikes"]: return 0, 0 a = unicode_map(sfd, sfd["strikes"][strike_px]) b = unicode_map(other, other["strikes"][strike_px]) both = [u for u in range(0x21, 0x7F) if u in a and u in b] return sum(1 for u in both if a[u]["pixels"] == b[u]["pixels"]), len(both) def build_font(w, strike_px, css, sfd, src, date, licence_url, metrics_mode, regular=None, allow_copied_bold=False, quotes="wqy", allow_pair_mismatch=False, vera_text=None, j_align="upstream"): strike = sfd["strikes"].get(strike_px) copied = None if w == "b": same, n = copied_from(strike_px, sfd, regular) if n and same / n >= COPIED_BOLD_LIMIT: if not allow_copied_bold: raise Fail(f"{src['file']} {strike_px} px: {same} of {n} ASCII bitmaps are pixel-identical to the regular " f"strike (only the advances are bold); not a bold design (--allow-copied-bold to build it anyway)") copied = f"{same}/{n} ASCII bitmaps identical to the regular {strike_px} px strike" if strike is None: raise Fail(f"{src['file']} has no {strike_px} px strike (has {sorted(sfd['strikes'])})") by_uni = unicode_map(sfd, strike) outline_adv = {g["unicode"]: g.get("width", 0) for g in sfd["glyphs"].values() if g.get("unicode", -1) >= 0} chars, missing, synthesized, left_out, modified = {}, [], [], [], [] drop = set() if quotes == "wqy": drop |= set(QUOTES) left_out.append({"chars": "".join(chr(u) for u in QUOTES), "why": QUOTES_WHY}) if strike_px in STRIKE_EXCLUDE: ex, why = STRIKE_EXCLUDE[strike_px] drop |= set(ex) left_out.append({"chars": "".join(chr(u) for u in ex), "why": why}) for u in CHARSET: if u in drop: continue ch = by_uni.get(u) if u in SPACES: if ch is not None and ch["pixels"]: raise Fail(f"{strike_px}px U+{u:04X} has ink") if ch is None or ch["advance"] == 0: adv = int(strike_px * outline_adv[u] / sfd["em"] + 0.5) synthesized.append({"char": f"U+{u:04X}", "advancePx": adv, "why": "not in strike" if ch is None else "0 px in strike", "rule": f"round({strike_px} x {outline_adv[u]} / {sfd['em']})"}) ch = {"advance": adv, "pixels": set()} chars[u] = ch elif ch is None: missing.append(f"U+{u:04X}") else: chars[u] = ch if quotes == "mirror": # a broken opening quote is redrawn as the left-right mirror of the same strike's closing # quote, inside the closing quote's ink box (at 11 and 12 px Wine's own ‘ “ are exactly that) for o, c in QUOTE_PAIRS: if o in chars and c in chars and pair_sig(chars[o]) != pair_sig(chars[c]): cp = chars[c]["pixels"] x0, x1 = min(x for x, y in cp), max(x for x, y in cp) new = {(x0 + x1 - x, y) for x, y in cp} modified.append({"char": chr(o), "from": chr(c), "rule": f"left-right mirror of {chr(c)}", "pixelsBefore": len(chars[o]["pixels"]), "pixelsAfter": len(new)}) chars[o] = dict(chars[o], pixels=new) metrics_mod = [] if j_align == "stem": for g, ref in J_ALIGN: if g in chars and ref in chars: rec = align_stem(chars, g, ref) if rec["shiftPx"]: chars[g] = dict(chars[g], pixels=rec.pop("pixels"), advance=rec["advancePx"][1]) metrics_mod.append(rec) pair_bad = pair_mismatches(chars) if pair_bad and not allow_pair_mismatch: raise Fail(f"{src['file']} {strike_px} px: paired marks differ: " + "; ".join(f"{m['pair']} {m[m['pair'][0]]} vs {m[m['pair'][1]]}" for m in pair_bad) + " (leave them out, --quotes mirror for the quotes, or --allow-pair-mismatch for a comparison build)") if metrics_mode == "strike": asc, desc = strike["ascent"], strike["descent"] else: if css not in VMETRICS: raise Fail(f"no vertical metrics for CSS {css}px (VMETRICS has {sorted(VMETRICS)})") asc, desc = VMETRICS[css] upm = UNITS * css # .notdef: a hollow box, cap height tall cap = max(y for (x, y) in by_uni[ord("H")]["pixels"]) + 1 bw = max(3, (cap * 5 + 5) // 10) notdef = {(x, y) for x in range(1, 1 + bw) for y in range(cap) if x in (1, bw) or y in (0, cap - 1)} order = [".notdef"] + [f"uni{u:04X}" for u in sorted(chars)] srcpix = {".notdef": notdef} advances = {".notdef": bw + 2} for u, ch in chars.items(): srcpix[f"uni{u:04X}"] = ch["pixels"] advances[f"uni{u:04X}"] = ch["advance"] glyphs = {} for gname in order: pen = TTGlyphPen(None) for contour in trace(srcpix[gname]): pen.moveTo((contour[0][0] * UNITS, contour[0][1] * UNITS)) for (x, y) in contour[1:]: pen.lineTo((x * UNITS, y * UNITS)) pen.closePath() glyphs[gname] = pen.glyph() fb = FontBuilder(upm, isTTF=True) fb.setupGlyphOrder(order) fb.setupCharacterMap({u: f"uni{u:04X}" for u in chars}) fb.setupGlyf(glyphs) glyf = fb.font["glyf"] hm = {} for gname in order: g = glyf[gname] g.recalcBounds(glyf) hm[gname] = (advances[gname] * UNITS, g.xMin if g.numberOfContours else 0) fb.setupHorizontalMetrics(hm) fb.setupHorizontalHeader(ascent=asc * UNITS, descent=-desc * UNITS, lineGap=0) fam = family_name(strike_px) style = src["style"] full = f"{fam} {style}" if style != "Regular" else fam ps = f"IISPIGPixelLatin{strike_px}-{style}" upstream_copyright = sfd["head"]["Copyright"] copyright_ = (f"{upstream_copyright} | Bitstream Vera Sans: {VERA_NOTICE} | " f"Wine: Copyright (c) 1993-2026 the Wine project authors.") moved = [m for m in metrics_mod if m["shiftPx"]] desc_ = (f"Outline (pixel-square) copy of the {strike_px} px {style.lower()} bitmap strike in Wine's " f"fonts/{src['file']} (LGPL-2.1-or-later), drawn for CSS font-size {css}px " f"(unitsPerEm {upm}, 1 bitmap px = {UNITS} units = 1 CSS px). Modified by iispig.com: " f"bitmaps converted to outlines {CONVERSION_RULES_DATE} (rules) / {date} (this build), " f"renamed, reduced to {len(chars)} characters, vertical metrics set to {asc}/{desc} px" + (", space advance(s) synthesised" if synthesized else "") + ("; " + ", ".join(f"{m['char']} moved {m['shiftPx']} px left with its advance reduced by " f"{m['shiftPx']} px (stem over {m['ref']}'s, metrics only)" for m in moved) + f" ({J_ALIGN_DATE})" if moved else "") + ("; " + ", ".join(f"{m['char']} redrawn as the {m['rule']}" for m in modified) + " (upstream opening quote broken); other glyph pixels unchanged." if modified else "; glyph pixels unchanged.")) lang = sfd["head"].get("LangName1033") or [] upstream13 = lang[13] if len(lang) > 13 else "" upstream14 = lang[14] if len(lang) > 14 else "" if "Lesser General Public License" not in upstream13: raise Fail(f"{src['file']}: no LGPL notice in LangName 1033 string 13; cannot keep it intact") # LGPL-2.1 sections 1/2: the upstream notice (licence + absence of warranty) is kept verbatim lic = (upstream13 + "\n" "IISPIG Pixel Latin is a modified version of this font (changes and their date: name ID 10). " "It is derived from Bitstream Vera Sans; the Bitstream Vera licence and notices also apply and, " "as that licence requires of modified fonts, its name contains neither of the words reserved by it. " "Full licence texts, the dated list of changes, the corresponding source (Wine's SFD files) and the " "converter: see the licence URL (name ID 14)." + (f" LGPL: {upstream14} (upstream licence URL)." if upstream14 else "")) if vera_text: lic += "\n\nBitstream Vera licence:\n" + vera_text names = {"copyright": copyright_, "familyName": fam, "styleName": style, "uniqueFontIdentifier": f"{ps};{VERSION};{date}", "fullName": full, "psName": ps, "version": f"Version {VERSION}; {SCRIPT_NAME} {date}", # the SFD names Larry Snyder (2004, the Vera-based font); the bitmap strikes carry no author "manufacturer": "iispig.com (conversion)", "designer": "Larry Snyder; the Wine project authors", "description": desc_, "vendorURL": "https://iispig.com/", "licenseDescription": lic, "licenseInfoURL": licence_url} for key in ("familyName", "styleName", "uniqueFontIdentifier", "fullName", "psName"): if not name_ok(names[key]): raise Fail(f"name {key} {names[key]!r} contains a reserved word") fb.setupNameTable(names, mac=False) def ink_h(u): p = chars.get(u, {}).get("pixels") if u in chars else None return (max(y for x, y in p) + 1) if p else 0 fs_sel = 0x80 | (0x20 if w == "b" else 0x40) fb.setupOS2(version=4, usWeightClass=src["weight"], usWidthClass=5, fsType=0, sTypoAscender=asc * UNITS, sTypoDescender=-desc * UNITS, sTypoLineGap=0, usWinAscent=asc * UNITS, usWinDescent=desc * UNITS, fsSelection=fs_sel, achVendID="NONE", sxHeight=ink_h(ord("x")) * UNITS, sCapHeight=ink_h(ord("H")) * UNITS, usDefaultChar=0, usBreakChar=0x20, usMaxContext=0, ySubscriptXSize=upm * 65 // 100, ySubscriptYSize=upm * 60 // 100, ySubscriptYOffset=upm * 7 // 100, ySuperscriptXSize=upm * 65 // 100, ySuperscriptYSize=upm * 60 // 100, ySuperscriptYOffset=upm * 35 // 100, yStrikeoutSize=UNITS, yStrikeoutPosition=(ink_h(ord("x")) // 2) * UNITS) os2 = fb.font["OS/2"] os2.panose.bFamilyType = 2 os2.panose.bWeight = 8 if w == "b" else 5 os2.recalcUnicodeRanges(fb.font) os2.recalcCodePageRanges(fb.font) os2.updateFirstAndLastCharIndex(fb.font) nz = [a for a in hm.values() if a[0]] os2.xAvgCharWidth = round(sum(a[0] for a in nz) / len(nz)) fb.setupPost(keepGlyphNames=False, underlinePosition=-UNITS, underlineThickness=UNITS, isFixedPitch=0) fb.setupMaxp() stamp = timestampSinceEpoch(int(datetime.datetime.strptime(date, "%Y-%m-%d") .replace(tzinfo=datetime.timezone.utc).timestamp())) head = fb.font["head"] head.created = head.modified = stamp head.fontRevision = float(VERSION) head.macStyle = 0x01 if w == "b" else 0 head.lowestRecPPEM = css head.flags = 0x000B # baseline at y=0, lsb at x=0, integer ppem fb.font.recalcTimestamp = False # compile once, reopen, check every glyph against its bitmap from io import BytesIO buf = BytesIO() fb.font.save(buf) ttf_bytes = buf.getvalue() font = TTFont(BytesIO(ttf_bytes), recalcTimestamp=False) gl = font["glyf"] reopened = font.getGlyphOrder() # post format 3: names are rebuilt, the order is the same if len(reopened) != len(order): raise Fail(f"{len(reopened)} glyphs after compiling, expected {len(order)}") cmap_back = font.getBestCmap() if {u: reopened[order.index(f"uni{u:04X}")] for u in chars} != cmap_back: raise Fail("cmap changed after compiling") problems, overhang, beyond = [], [], [] for gname, gname2 in zip(order, reopened): g = gl[gname2] back = rasterise(g, gl) if back != srcpix[gname]: problems.append(f"{gname}: round trip differs ({len(back ^ srcpix[gname])} px)") if g.numberOfContours > 0: coords, ends, _ = g.getCoordinates(gl) pts = [tuple(c) for c in coords] if any(x % UNITS or y % UNITS for x, y in pts): problems.append(f"{gname}: point off the {UNITS}-unit grid") start = 0 for e in ends: c = pts[start:e + 1] if len(set(c)) != len(c): problems.append(f"{gname}: contour reuses a point") for k in range(len(c)): (x0, y0), (x1, y1) = c[k], c[(k + 1) % len(c)] if x0 != x1 and y0 != y1: problems.append(f"{gname}: diagonal segment") start = e + 1 if g.xMin < 0 or g.xMax > advances[gname] * UNITS: # GDI-style overhang (e.g. bold T): kept as drawn, recorded in the manifest u = int(gname[3:], 16) if gname.startswith("uni") else None overhang.append({"char": chr(u) if u else gname, "inkPx": [g.xMin // UNITS, g.xMax // UNITS], "advancePx": advances[gname]}) if g.yMax > asc * UNITS or g.yMin < -desc * UNITS: if metrics_mode == "strike": # test builds: the strike's own numbers may be too small beyond.append(gname) else: problems.append(f"{gname}: ink outside ascent/descent ({g.yMin}..{g.yMax})") if problems: raise Fail(f"{fam} {style}: " + "; ".join(problems[:12])) font.flavor = "woff2" buf2 = BytesIO() font.save(buf2) woff2 = buf2.getvalue() def box(u): p = chars[u]["pixels"] if not p: return None return [min(x for x, y in p), min(y for x, y in p), max(x for x, y in p) + 1, max(y for x, y in p) + 1] own_space = chars[0x20]["advance"] if 0x20 in chars else 0 gaps = gap_check(chars, css, own_space) # advancePx = what the page draws with the recommended CSS (option A): at 12/14/16px the spaces are # not in the unicode-range, WenQuanYi draws them, so their advances are WenQuanYi's; this font's own # space advances (drawn only under option B) are in advancePxNotRendered adv_px = {chr(u): chars[u]["advance"] for u in sorted(chars)} not_rendered = {} if css in WQY_SPACE_PX: for u in SPACES: if u in chars: not_rendered[chr(u)] = adv_px[chr(u)] adv_px[chr(u)] = WQY_SPACE_PX[css][u] info = { "build": f"{w}{strike_px}@{css}", "family": fam, "style": style, "weight": src["weight"], "fullName": full, "psName": ps, "strikePx": strike_px, "cssPx": css, "unitsPerEm": upm, "sizeAdjust": "none (1 bitmap px = 1 CSS px at font-size %dpx)" % css, "metricsMode": metrics_mode, "ascentPx": asc, "descentPx": desc, "strikeAscentPx": strike["ascent"], "strikeDescentPx": strike["descent"], "xHeightPx": ink_h(ord("x")), "capHeightPx": ink_h(ord("H")), "chars": len(chars), "charset": "".join(chr(u) for u in sorted(chars)), # option A for sizes with a WenQuanYi face after this one (no spaces); 10px carries its own "unicodeRange": urange(u for u in chars if css not in WQY_SIZES or u not in SPACES), "unicodeRangeAll": urange(chars), "quotes": quotes, "leftOut": left_out, "modified": modified, "jAlign": j_align, "metricsModified": metrics_mod, "pairCheck": {"pairs": " ".join(chr(a) + chr(b) for a, b in PAIRS if a in chars and b in chars), "mismatch": pair_bad}, "gapCheck": gaps, "missing": missing, "synthesized": synthesized, "overhang": overhang, "boldStrikeCopiedFromRegular": copied, "inkBeyondMetrics": [chr(int(n[3:], 16)) for n in beyond if n.startswith("uni")], "advancePx": adv_px, "spaceAdvanceFrom": (f"WenQuanYi {css}px: U+0020/U+00A0 are not in this face's unicodeRange (option A)" if not_rendered else "this font"), "advancePxNotRendered": not_rendered, "inkBoxPx": {chr(u): box(u) for u in sorted(chars) if chars[u]["pixels"]}, "contours": sum(max(0, gl[g].numberOfContours) for g in reopened), "ttfBytes": len(ttf_bytes), "bytes": len(woff2), "sha256": hashlib.sha256(woff2).hexdigest(), } return woff2, info # ---------------------------------------------------------------- licence bundle def find_licence(pub, rel, sfd_dir): """A licence input: vendor/wine-tahoma/, else next to this script (a published bundle). The output folder is never an input.""" for cand in (os.path.join(sfd_dir, rel), os.path.join(HERE, pub)): if os.path.isfile(cand): return cand return None def git_blob(data): return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest() def notice_text(manifest, date, licence_url): fonts = manifest["fonts"] src = manifest["sources"] L = [] L.append("IISPIG Pixel Latin: pixel fonts made from Wine's Tahoma-compatible bitmap strikes") L.append("=" * 80) L.append("") L.append("What : vector (pixel-square) web fonts, one per bitmap strike, for the Latin") L.append(" text of iispig.com (the Chinese forum and the English page).") L.append("Source : Wine, " + " and ".join(f"fonts/{s['file']}" for s in src.values()) + " (FontForge SFD),") L.append(" copied unchanged into source/ next to this notice:") for s in src.values(): L.append(f" source/{s['file']} {s['bytes']} bytes, sha256 {s['sha256']}") L.append(f" git blob {s['gitBlob']}") L.append(f" from {s['url']} (Wine's master branch,") L.append(f" downloaded {manifest['sourceDownloaded']}); the same bytes by blob id:") L.append(f" {s['pinnedUrl']}") for p in manifest.get("sourcesPublished", []): if not p["usedByFonts"]: L.append(f" source/{p['file']} {p['bytes']} bytes, sha256 {p['sha256']}") L.append(f" git blob {p['gitBlob']}: Wine's other Tahoma SFD, published here too;") L.append(" no font in this folder is made from it.") L.append("Copyright : (SFD copyright line verbatim; the Wine and Bitstream notices from their licence files)") for s in src.values(): L.append(f" {s['file']}: {s['copyright']}") L.append(" Wine: Copyright (c) 1993-2026 the Wine project authors (Wine-LICENSE.txt).") L.append(f" Bitstream Vera Sans: {VERA_NOTICE}") L.append("Licence : GNU Lesser General Public License, version 2.1 or (at your option) any later") L.append(" version (LGPL-2.1.txt, Wine-LICENSE.txt). The source fonts are based on") L.append(" Bitstream Vera Sans, so the Bitstream Vera licence also applies") L.append(" (Bitstream-Vera-LICENSE.txt: copyright, trademark and permission notice).") L.append(" As that licence requires of modified fonts, the font names contain neither") L.append(" \"Bitstream\" nor \"Vera\". \"Tahoma\" (a trademark of Microsoft Corporation) is") L.append(" not used in any font name either; it appears here only as the name of") L.append(" Wine's source files.") L.append(" Name ID 13 of every font keeps the upstream LGPL notice of the SFD files") L.append(" verbatim (licence and absence of warranty) and adds a note on Bitstream Vera.") L.append(f"Licence URL : {licence_url} (name ID 14)") L.append("") L.append("MODIFICATIONS (LGPL-2.1 section 2(b): notice of changes and their date)") L.append("-" * 72) L.append(f"Modified by iispig.com with {SCRIPT_NAME} (a copy is in this folder; it needs the packages") L.append(f"in sfd-bitmap-to-ttf-requirements.txt). Generator: fontTools {manifest['generator']['fontTools']}, " f"brotli {manifest['generator']['brotli']}.") L.append(f" Conversion rules last changed : {CONVERSION_RULES_DATE}") L.append(f" These files built : {date}") L.append("") mods = [(f, m) for f in fonts for m in f.get("modified", [])] moved = [(f, m) for f in fonts for m in f.get("metricsModified", []) if m["shiftPx"]] n_item = 7 item_mod = item_moved = None if mods: item_mod, n_item = n_item, n_item + 1 if moved: item_moved, n_item = n_item, n_item + 1 L.append("1. Each listed bitmap strike was converted to TrueType outlines: every set pixel") L.append(f" became a {UNITS} x {UNITS} unit square and touching squares were merged into one") if mods or moved: exc = " and ".join(([f"the redrawn quotes in item {item_mod}"] if mods else []) + ([f"the glyphs moved as a whole in item {item_moved}"] if moved else [])) L.append(f" outline. Apart from {exc}, no pixel was") L.append(" added, removed or moved; the build checks every glyph by rasterising it back.") L.append(" Advance widths are the strike's own" + (f" (except item {item_moved})." if moved else ".")) else: L.append(" outline. No pixel was added, removed or moved; the build checks every glyph") L.append(" by rasterising it back. Advance widths are the strike's own.") L.append("2. unitsPerEm = 100 x the CSS pixel size the font is meant for, so one bitmap") other = sorted({(f["strikePx"], f["cssPx"]) for f in fonts if f["strikePx"] != f["cssPx"]}) L.append(" pixel is one CSS pixel at that size" + (" (" + ", ".join(f"the {s} px strike is drawn for {c}px" for s, c in other) + ")." if other else ".")) L.append("3. Vertical metrics (hhea, OS/2) set to the line metrics of the WenQuanYi face used") L.append(" at the same size (12/14px: ascent 12 + descent 3 px; 16px: 14 + 4); 10px, which has") L.append(" no WenQuanYi face: the strike's 8 + 2 plus one row (9 + 2). Underline 1 px, 1 px below") L.append(" the baseline. A .notdef box (never shown with the unicode-range in use) was added.") L.append("4. Reduced to the characters listed below; dropped: all other glyphs, the outline") L.append(" glyphs, other strikes, kerning and OpenType layout, hinting, VDMX, glyph names.") outs = {} for f in fonts: for lo in f.get("leftOut", []): outs.setdefault((lo["chars"], lo["why"]), []).append(f["fullName"]) for (chs, why), names in outs.items(): L.append(f" Also left out: {' '.join(chs)} ({', '.join(names)}): {why}.") L.append("5. Spaces: where a strike has no space glyph (11 px and up) or a 0 px no-break") L.append(" space (16 px), the advance is round(px x outline advance / 2048), as FreeType") L.append(" does for a glyph missing from a strike.") L.append("6. Renamed (name table rewritten: IDs 0-6, 8-11, 13, 14; ID 13 keeps the upstream") L.append(" licence notice verbatim and adds a note); converted to WOFF2.") if mods: L.append(f"{item_mod}. Redrawn ({CONVERSION_RULES_DATE}): the upstream opening quote is broken in these strikes, so it") L.append(" was replaced by the left-right mirror of the same strike's closing quote:") for f, m in mods: L.append(f" {f['fullName']}: {m['char']} = {m['rule']} ({m['pixelsBefore']} -> {m['pixelsAfter']} px)") if moved: L.append(f"{item_moved}. Moved ({J_ALIGN_DATE}), metrics only: the strikes draw j (and J) with the hook at x >= 0,") L.append(" so the stem stands right of i's (I's) and the gap before the letter is close to a") L.append(" word space; the SFD outline has i's and j's stems in the same place. Each glyph below") L.append(" was moved left as a whole (pixels unchanged, the hook now overhangs the origin) and") L.append(" its advance reduced by the same amount, so the right side bearing is unchanged:") for f, m in moved: L.append(f" {f['fullName']}: {m['char']} {m['shiftPx']} px left (stem over {m['ref']}'s), " f"advance {m['advancePx'][0]} -> {m['advancePx'][1]} px") L.append("") L.append("FILES") L.append("-----") for f in fonts: L.append(f"{f['file']} {f['bytes']} bytes sha256 {f['sha256']}") L.append(f" {f['fullName']}: {f['strikePx']} px {f['style'].lower()} strike of {SOURCES[f['build'][0]]['file']}, " f"for font-size:{f['cssPx']}px, ascent/descent {f['ascentPx']}/{f['descentPx']} px, {f['chars']} characters") L.append(f" characters: {f['unicodeRangeAll']}") if f["missing"]: L.append(f" not in this strike (not included): {' '.join(f['missing'])}") for s in f["synthesized"]: L.append(f" synthesised: {s['char']} advance {s['advancePx']} px = {s['rule']} ({s['why']})") L.append("") L.append("SOURCE CODE (LGPL-2.1 section 4)") L.append("--------------------------------") L.append("The preferred form for modification is the SFD file(s) in source/ plus the converter.") L.append(f"{SCRIPT_NAME}: Copyright (c) 2026 iispig.com, licensed under the GNU Lesser General") L.append("Public License 2.1 or (at your option) any later version (LGPL-2.1.txt), like the fonts.") L.append("To rebuild, from this folder:") q = manifest.get("quotes", "wqy") L.append(f" python {SCRIPT_NAME} OUT_DIR --sfd-dir source --licence-dir NEW_LICENCE_DIR --date {date}" + (f" --quotes {q}" if q != "wqy" else "") + f" --j-align {manifest.get('jAlign', 'upstream')}" + (" --embed-vera-licence" if manifest.get("veraEmbedded") else "") + (f" --licence-url {licence_url}" if licence_url != DEFAULT_LICENCE_URL else "")) L.append("with the packages in sfd-bitmap-to-ttf-requirements.txt; the same date gives") L.append("byte-identical fonts.") L.append("") L.append("THIS FOLDER") L.append("-----------") for pub, _, desc in LICENCE_FILES: L.append(f"{pub:34} {desc}") L.append(f"{'PXLATIN-NOTICE.txt':34} this notice") for s in src.values(): L.append(f"{'source/' + s['file']:34} Wine's SFD file, unchanged (the source)") for p in manifest.get("sourcesPublished", []): if not p["usedByFonts"]: L.append(f"{'source/' + p['file']:34} Wine's SFD file, unchanged; not used by these fonts") L.append(f"{'source/SOURCE.txt':34} where the SFD files came from, when, and their hashes") L.append(f"{SCRIPT_NAME:34} the converter") L.append(f"{'sfd-bitmap-to-ttf-requirements.txt':34} Python packages for the converter") L.append(f"{'pxlatin-manifest.json':34} what was built: metrics, advances, characters, hashes") L.append(f"{'index.html':34} list of these files") L.append("") L.append(f"Bitstream-Vera-LICENSE.txt is the text of {VERA_SOURCE}") L.append(f"(sha256 {VERA_SHA256}). Its first line (\"Copyright Copyright (c) 2003 ...\") is SPDX's") L.append("rendering of the copyright line of Bitstream's original COPYRIGHT.TXT.") return "\n".join(L) + "\n" def source_txt(manifest): """source/SOURCE.txt of the published bundle: only the SFDs that were copied, under the bundle's own file names (the vendor folder's SOURCE.txt also lists files this bundle does not contain).""" src = manifest["sources"] pub = {p["file"]: p for p in manifest.get("sourcesPublished", [])} extra = [p for f, p in pub.items() if not p["usedByFonts"]] L = ["Corresponding source of the IISPIG Pixel Latin fonts (LGPL-2.1 section 4), copied unchanged", "from Wine's fonts/ directory into this folder:", ""] for s in list(src.values()) + [{**p, "url": UPSTREAM + p["file"], "pinnedUrl": PINNED.format(blob=p["gitBlob"])} for p in extra]: L.append(f" {s['file']} {s['bytes']} bytes, sha256 {s['sha256']}" + ("" if pub.get(s["file"], {"usedByFonts": True})["usedByFonts"] else "\n (not used by the fonts in this folder, which use no bold strike; published with them" "\n as the rest of Wine's Tahoma source)")) L.append(f" git blob {s['gitBlob']} (git hash-object: the file's content address in Wine's repository)") L.append(f" from {s['url']} (Wine's master branch)") L.append(f" the same bytes by blob id: {s['pinnedUrl']}") L.append(f"Downloaded: {manifest['sourceDownloaded']}") L.append("Commit / tag: not recorded; the blob ids above pin the bytes.") unused = [v["file"] for k, v in SOURCES.items() if k not in src and v["file"] not in pub] if unused: L.append(f"Not used by these fonts, so not included: {', '.join(unused)}.") L += ["", "In the folder above: LGPL-2.1.txt (Wine's COPYING.LIB), Wine-LICENSE.txt (Wine's LICENSE),", "Bitstream-Vera-LICENSE.txt (" + VERA_SOURCE + ",", f" sha256 {VERA_SHA256}; its first line \"Copyright Copyright (c) 2003 ...\" is SPDX's", " rendering of the copyright line of Bitstream's original COPYRIGHT.TXT),", f"PXLATIN-NOTICE.txt (the dated changes and how to rebuild) and {SCRIPT_NAME} (the converter)."] return "\n".join(L) + "\n" def index_html(files, fonts, notice): rows = "\n".join(f'{html.escape(n)}{b:,}{html.escape(d)}' for n, b, d in files) frows = "\n".join(f'{html.escape(p.split("/")[-1])}{b:,}' f'{html.escape(d)}' for p, b, d in fonts) return f""" Pixel Latin licences

IISPIG Pixel Latin: licences and source

Fonts converted from Wine's bitmap strikes. LGPL-2.1-or-later; Bitstream Vera notice. The source (Wine's SFD files) is in source/. Full notice below.

{rows}
filebyteswhat

Fonts

{frows}
filebyteswhat
{html.escape(notice)}
""" # ---------------------------------------------------------------- main UTF8_BOM = "\ufeff" def write_text(path, text): """A text file of the licence bundle. With non-ASCII characters it starts with a UTF-8 byte order mark, so a browser decodes it as UTF-8 even when the server sends text/plain without a charset.""" if any(ord(c) > 0x7F for c in text) and not text.startswith(UTF8_BOM): text = UTF8_BOM + text with open(path, "w", encoding="utf-8") as fh: fh.write(text) def build_date(arg): if arg: if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", arg): raise SystemExit(f"--date must be YYYY-MM-DD, not {arg!r}") d = arg elif os.environ.get("SOURCE_DATE_EPOCH"): d = datetime.datetime.fromtimestamp(int(os.environ["SOURCE_DATE_EPOCH"]), datetime.timezone.utc).strftime("%Y-%m-%d") else: d = datetime.date.today().isoformat() print(f"warning: no --date or SOURCE_DATE_EPOCH; using today ({d})", file=sys.stderr) return max(d, CONVERSION_RULES_DATE) def main(): ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) ap.add_argument("out_dir") ap.add_argument("--sfd-dir", default=os.path.join(SITE, "vendor", "wine-tahoma")) ap.add_argument("--build", action="append", help="r|b@, repeatable (default: %s)" % " ".join(DEFAULT_BUILDS)) ap.add_argument("--metrics", choices=("wqy", "strike"), default="wqy", help="vertical metrics: the WQY line metrics for the CSS size (default) or the strike's own (test)") ap.add_argument("--quotes", choices=("wqy", "upstream", "mirror"), default="wqy", help="curly quotes: left out, WenQuanYi supplies them (default); as drawn upstream (comparison); " "broken opening quotes redrawn as the mirror of the closing ones (changes pixels: owner approval)") ap.add_argument("--j-align", choices=("upstream", "stem"), default="stem", help="j/J placement: stem = moved left so the stem is over i's/I's, advance reduced by the same " "(metrics only; the default, approved 2026-10-18); upstream = as in the strikes (comparison)") ap.add_argument("--sources", choices=("all", "used"), default="all", help="SFD files copied into /source/: all = every pinned SFD (tahoma.sfd and " "tahomabd.sfd; the default, the owner's decision), used = only those the built fonts use") ap.add_argument("--suffix", default="", help="extra text in output file names (test builds)") ap.add_argument("--unpinned", action="store_true", help="accept SFDs whose SHA-256 differs from the pinned one") ap.add_argument("--allow-copied-bold", action="store_true", help="build bold strikes that are copies of the regular bitmaps (13/15/16 px; test only)") ap.add_argument("--allow-pair-mismatch", action="store_true", help="build fonts whose paired marks differ (e.g. --quotes upstream at 13/15/16 px; test only)") ap.add_argument("--date", help="build date YYYY-MM-DD (default SOURCE_DATE_EPOCH, else today)") ap.add_argument("--css", help="write the @font-face rules of the recommended faces (PXL10/12/14/16) to this file") ap.add_argument("--url-prefix", default=DEFAULT_URL_PREFIX, help="published URL of the font folder, for --css and the licence index (default %(default)s)") ap.add_argument("--licence-dir", help="licence bundle folder (default OUT_DIR/../licenses)") ap.add_argument("--no-licence", action="store_true", help="skip the licence bundle and the source copy (test builds that are not published)") ap.add_argument("--licence-url", default=DEFAULT_LICENCE_URL, help="published URL of the licence bundle (name ID 14)") ap.add_argument("--embed-vera-licence", action="store_true", help="also put the whole Bitstream Vera licence into name ID 13 (about +1 KB per font)") ap.add_argument("--json", action="store_true", help="print the manifest on stdout") args = ap.parse_args() date = build_date(args.date) out_dir = os.path.abspath(args.out_dir) os.makedirs(out_dir, exist_ok=True) licence_dir = os.path.abspath(args.licence_dir or os.path.join(out_dir, "..", "licenses")) builds = [parse_spec(s) for s in (args.build or DEFAULT_BUILDS)] vera_text = None if not args.no_licence or args.embed_vera_licence: need = [(pub, find_licence(pub, rel, args.sfd_dir)) for pub, rel, _ in LICENCE_FILES] lost = [pub for pub, p in need if not p] if lost: print(f"error: licence input(s) missing: {', '.join(lost)} (looked in {args.sfd_dir}/licenses, {HERE})", file=sys.stderr) return 1 vera = dict(need)["Bitstream-Vera-LICENSE.txt"] with open(vera, "rb") as fh: vb = fh.read() if hashlib.sha256(vb).hexdigest() != VERA_SHA256: print(f"error: {vera} is not the expected Bitstream Vera licence text", file=sys.stderr) return 1 if args.embed_vera_licence: # SPDX's first line reads "Copyright Copyright (c) 2003 ..."; the font gets the notice itself first, _, rest = vb.decode("utf-8").strip().partition("\n") if first != "Copyright " + VERA_NOTICE: print(f"error: {vera}: unexpected first line {first[:60]!r}", file=sys.stderr) return 1 vera_text = VERA_NOTICE + "\n" + rest try: sfds, sources, sfd_bytes = {}, {}, {} for w in sorted({b[0] for b in builds} | ({"r"} if any(b[0] == "b" for b in builds) else set())): src = SOURCES[w] path = os.path.join(args.sfd_dir, src["file"]) with open(path, "rb") as fh: data = fh.read() sha, blob = hashlib.sha256(data).hexdigest(), git_blob(data) if (sha != src["sha256"] or blob != src["gitBlob"]) and not args.unpinned: print(f"error: {path} sha256 {sha} / git blob {blob} is not the pinned {src['sha256']} / " f"{src['gitBlob']} (--unpinned to accept)", file=sys.stderr) return 1 sfds[w] = parse_sfd(path) sfd_bytes[w] = path sources[w] = {"file": src["file"], "url": UPSTREAM + src["file"], "bytes": len(data), "sha256": sha, "gitBlob": blob, "pinnedUrl": PINNED.format(blob=blob), "copyright": sfds[w]["head"].get("Copyright", ""), "strikes": sorted(sfds[w]["strikes"])} fonts = [] for w, strike_px, css in builds: woff2, info = build_font(w, strike_px, css, sfds[w], SOURCES[w], date, args.licence_url, args.metrics, regular=sfds.get("r"), allow_copied_bold=args.allow_copied_bold, quotes=args.quotes, allow_pair_mismatch=args.allow_pair_mismatch, vera_text=vera_text, j_align=args.j_align) if info["pairCheck"]["mismatch"]: print(f"warning: {info['fullName']}: paired marks differ (--allow-pair-mismatch): " + ", ".join(m["pair"] for m in info["pairCheck"]["mismatch"]), file=sys.stderr) gc = info["gapCheck"] if gc["flaggedPairs"]: print(f"warning: {info['fullName']} ({info['build']}): above-baseline gap >= {gc['thresholdPx']} px " f"(word gap {gc['wordGapPx']} - 1) in {gc['flaggedPairs']} letter pairs: {', '.join(gc['summary'])}", file=sys.stderr) for m in info["metricsModified"]: print(f"note: {info['fullName']}: {m['char']} moved {m['shiftPx']} of {m['fullShiftPx']} px" + "".join(f"; {b['shiftPx']} px blocked: {b['why']}" + (f" ({' '.join(b['pairs'])})" if b.get("pairs") else "") for b in m["blocked"]) + (f"; reported only: {' '.join(m['overlapsReportOnly'])}" if m.get("overlapsReportOnly") else ""), file=sys.stderr) stem = file_stem(w, strike_px, css) + args.suffix fname = f"{stem}.{info['sha256'][:8]}.woff2" for old in os.listdir(out_dir): if re.fullmatch(re.escape(stem) + r"\.[0-9a-f]{8}\.woff2", old) and old != fname: os.remove(os.path.join(out_dir, old)) with open(os.path.join(out_dir, fname), "wb") as fh: fh.write(woff2) info["file"] = fname fonts.append(info) except Fail as e: print(f"error: {e}", file=sys.stderr) return 3 # SFD files published in /source/ (LGPL-2.1 section 4): the ones the fonts use, plus with # --sources all (the default) every other pinned SFD, so Wine's whole Tahoma source sits next to the fonts. published = {w: {**s, "usedByFonts": True} for w, s in sources.items()} if args.sources == "all" and not args.no_licence: for w, src in SOURCES.items(): if w in published: continue path = os.path.join(args.sfd_dir, src["file"]) try: with open(path, "rb") as fh: data = fh.read() except OSError as e: print(f"error: --sources all: {path}: {e.strerror}", file=sys.stderr) return 1 sha, blob = hashlib.sha256(data).hexdigest(), git_blob(data) if (sha != src["sha256"] or blob != src["gitBlob"]) and not args.unpinned: print(f"error: {path} sha256 {sha} / git blob {blob} is not the pinned {src['sha256']} / " f"{src['gitBlob']} (--unpinned to accept)", file=sys.stderr) return 1 sfd_bytes[w] = path published[w] = {"file": src["file"], "url": UPSTREAM + src["file"], "bytes": len(data), "sha256": sha, "gitBlob": blob, "pinnedUrl": PINNED.format(blob=blob), "usedByFonts": False} sdl = None sdl_path = os.path.join(args.sfd_dir, "SOURCE.txt") if os.path.isfile(sdl_path): m = re.search(r"Downloaded:\s*(\S+)", open(sdl_path, encoding="utf-8").read()) sdl = m.group(1) if m else None manifest = { "tool": SCRIPT_NAME, "conversionRulesDate": CONVERSION_RULES_DATE, "buildDate": date, "generator": {"fontTools": fontTools.version, "brotli": getattr(brotli, "__version__", "?")}, "unitsPerPixel": UNITS, "metricsMode": args.metrics, "quotes": args.quotes, "jAlign": args.j_align, "veraEmbedded": bool(vera_text), "sources": sources, "sourceDownloaded": sdl or "unknown", "licenceURL": args.licence_url, "urlPrefix": args.url_prefix, "recommended": RECOMMENDED, "fonts": fonts, } if not args.no_licence: manifest["sourceCopied"] = [f"source/{s['file']}" for s in published.values()] manifest["sourcesPublished"] = [{k: s[k] for k in ("file", "bytes", "sha256", "gitBlob", "usedByFonts")} for s in published.values()] mpath = os.path.join(out_dir, "pxlatin-manifest.json" if not args.suffix else f"pxlatin-manifest{args.suffix}.json") with open(mpath, "w", encoding="utf-8") as fh: json.dump(manifest, fh, ensure_ascii=False, indent=1) fh.write("\n") if args.css: rec = [f for f in fonts if f["build"] in RECOMMENDED] skipped = [f["build"] for f in fonts if f["build"] not in RECOMMENDED] if not rec: print(f"error: --css: none of the recommended builds ({' '.join(RECOMMENDED)}) was built", file=sys.stderr) return 1 if skipped: print(f"note: --css: {' '.join(skipped)} not in the recommended set, not written", file=sys.stderr) rules = ["/* generated by tools/sfd-bitmap-to-ttf.py -- do not edit. The recommended set-up", " (design/latin-c/NOTES.md section 1); the site build takes this file verbatim.", " Weight 400 only: bold text keeps this face plus text-shadow:1px 0 0 currentColor;letter-spacing:1px,", " the same rule as the CJK (no bold face, so font-synthesis:none never mixes weights in a run).", " 12/14/16px: no U+0020/U+00A0 (option A), so the WQY face after it stays the first available font", " and supplies the spaces; 10px (.smfont, no WQY face) carries its own spaces.", " Curly quotes are not in these fonts: WenQuanYi draws them at every size.", " No size-adjust: unitsPerEm = 100 x the CSS size, 1 bitmap px = 1 CSS px. */"] for f in sorted(rec, key=lambda f: f["cssPx"]): rules.append(f'@font-face{{font-family:"{RECOMMENDED[f["build"]]}";src:url({args.url_prefix}{f["file"]}) ' f'format("woff2");font-weight:400;unicode-range:{f["unicodeRange"]};font-display:block}}') with open(args.css, "w", encoding="utf-8") as fh: fh.write("\n".join(rules) + "\n") if not args.no_licence: os.makedirs(licence_dir, exist_ok=True) listing = [] for pub, src_path in need: dst = os.path.join(licence_dir, pub) if os.path.abspath(src_path) != os.path.abspath(dst): shutil.copyfile(src_path, dst) # the corresponding source (LGPL-2.1 section 4), from the same place as the fonts sdir = os.path.join(licence_dir, "source") os.makedirs(sdir, exist_ok=True) keep = {s["file"] for s in published.values()} | {"SOURCE.txt"} for old in os.listdir(sdir): if old not in keep: os.remove(os.path.join(sdir, old)) for w, s in published.items(): dst = os.path.join(sdir, s["file"]) if not (os.path.isfile(dst) and os.path.samefile(sfd_bytes[w], dst)): shutil.copyfile(sfd_bytes[w], dst) with open(dst, "rb") as fh: if hashlib.sha256(fh.read()).hexdigest() != s["sha256"]: print(f"error: {dst} differs from the SFD the fonts were built from", file=sys.stderr) return 1 write_text(os.path.join(sdir, "SOURCE.txt"), source_txt(manifest)) me = os.path.abspath(__file__) if me != os.path.join(licence_dir, SCRIPT_NAME): # with a UTF-8 BOM when it has non-ASCII text (as write_text), so a browser shows it right without a # charset header; Python reads the BOM as the UTF-8 signature, so the copy still runs with open(me, "rb") as fh: code = fh.read() if any(b > 0x7F for b in code) and not code.startswith(UTF8_BOM.encode("utf-8")): code = UTF8_BOM.encode("utf-8") + code with open(os.path.join(licence_dir, SCRIPT_NAME), "wb") as fh: fh.write(code) req = os.path.join(HERE, "requirements.txt") if not os.path.isfile(req): req = os.path.join(HERE, "sfd-bitmap-to-ttf-requirements.txt") if os.path.abspath(req) != os.path.join(licence_dir, "sfd-bitmap-to-ttf-requirements.txt"): shutil.copyfile(req, os.path.join(licence_dir, "sfd-bitmap-to-ttf-requirements.txt")) shutil.copyfile(mpath, os.path.join(licence_dir, "pxlatin-manifest.json")) notice = notice_text(manifest, date, args.licence_url) write_text(os.path.join(licence_dir, "PXLATIN-NOTICE.txt"), notice) descs = {pub: d for pub, _, d in LICENCE_FILES} descs.update({"PXLATIN-NOTICE.txt": "copyright, licence, dated list of changes, source", SCRIPT_NAME: "the converter (LGPL-2.1-or-later; source of the changes)", "sfd-bitmap-to-ttf-requirements.txt": "Python packages the converter needs", "pxlatin-manifest.json": "what was built", "source/SOURCE.txt": "where the SFD files came from, when, and their hashes"}) for s in published.values(): descs[f"source/{s['file']}"] = (f"source: Wine's fonts/{s['file']}, unchanged (LGPL-2.1 section 4)" if s["usedByFonts"] else f"Wine's fonts/{s['file']}, unchanged: not used by these fonts, published " "with them as the rest of Wine's Tahoma source") names = [p for p, _, _ in LICENCE_FILES] + ["PXLATIN-NOTICE.txt"] + [f"source/{s['file']}" for s in published.values()] \ + (["source/SOURCE.txt"] if os.path.isfile(os.path.join(sdir, "SOURCE.txt")) else []) \ + [SCRIPT_NAME, "sfd-bitmap-to-ttf-requirements.txt", "pxlatin-manifest.json"] for pub in names: p = os.path.join(licence_dir, pub) if not os.path.isfile(p): print(f"error: bundle file missing after writing: {pub}", file=sys.stderr) return 1 listing.append((pub, os.path.getsize(p), descs[pub])) # font links: the published URLs (--url-prefix), not the local layout font_rows = [(args.url_prefix + f["file"] if args.url_prefix else os.path.relpath(os.path.join(out_dir, f["file"]), licence_dir), f["bytes"], f"{f['fullName']} ({f['strikePx']} px strike, for {f['cssPx']}px)") for f in fonts] with open(os.path.join(licence_dir, "index.html"), "w", encoding="utf-8") as fh: fh.write(index_html(listing, font_rows, notice)) if args.json: json.dump(manifest, sys.stdout, ensure_ascii=False, indent=1) print() else: for f in fonts: print(f"{f['file']:28} {f['bytes']:6} B {f['fullName']:28} {f['chars']} chars " f"asc/desc {f['ascentPx']}/{f['descentPx']} missing {' '.join(f['missing']) or '-'}") return 0 if __name__ == "__main__": sys.exit(main())