diff --git a/.github/workflows/warp.yml b/.github/workflows/warp.yml index f716a84..ac85877 100644 --- a/.github/workflows/warp.yml +++ b/.github/workflows/warp.yml @@ -59,6 +59,25 @@ jobs: exit 1 fi + - name: Check the build without AVX-512 + env: + NPY_DISABLE_CPU_FEATURES: "X86_V4 AVX512_ICL AVX512_SKX AVX512F" + RAYON_NUM_THREADS: "2" + run: | + # On a runner without AVX-512 this build repeats the first one and + # proves nothing new; the CPU tests in pytest cover that case. + if grep -qw avx512f /proc/cpuinfo; then + echo "AVX-512 is present: this build uses the other NumPy code path." + else + echo "::warning::This runner has no AVX-512, so this build repeats the default build." + fi + scripts/build.sh + if [ -n "$(git -C .. status --porcelain -- fonts/warp)" ]; then + git -C .. status --short -- fonts/warp + echo "::error::fonts/warp differs from a fresh build without AVX-512." + exit 1 + fi + - name: Check the outlines against the pen sweep (fidelity report) run: | python tools/fidelity.py --gate diff --git a/fonts/warp/otf/MachineMadeWarp-Bold.otf b/fonts/warp/otf/MachineMadeWarp-Bold.otf index 0406f6c..98abbb6 100644 Binary files a/fonts/warp/otf/MachineMadeWarp-Bold.otf and b/fonts/warp/otf/MachineMadeWarp-Bold.otf differ diff --git a/fonts/warp/otf/MachineMadeWarp-BoldItalic.otf b/fonts/warp/otf/MachineMadeWarp-BoldItalic.otf index 3699c63..bfe9c00 100644 Binary files a/fonts/warp/otf/MachineMadeWarp-BoldItalic.otf and b/fonts/warp/otf/MachineMadeWarp-BoldItalic.otf differ diff --git a/fonts/warp/otf/MachineMadeWarp-Regular.otf b/fonts/warp/otf/MachineMadeWarp-Regular.otf index 0a8184c..87f5c27 100644 Binary files a/fonts/warp/otf/MachineMadeWarp-Regular.otf and b/fonts/warp/otf/MachineMadeWarp-Regular.otf differ diff --git a/fonts/warp/ttf/MachineMadeWarp-Bold.ttf b/fonts/warp/ttf/MachineMadeWarp-Bold.ttf index 09ab78e..6771547 100644 Binary files a/fonts/warp/ttf/MachineMadeWarp-Bold.ttf and b/fonts/warp/ttf/MachineMadeWarp-Bold.ttf differ diff --git a/fonts/warp/ttf/MachineMadeWarp-BoldItalic.ttf b/fonts/warp/ttf/MachineMadeWarp-BoldItalic.ttf index f01c41b..5c427b7 100644 Binary files a/fonts/warp/ttf/MachineMadeWarp-BoldItalic.ttf and b/fonts/warp/ttf/MachineMadeWarp-BoldItalic.ttf differ diff --git a/fonts/warp/ttf/MachineMadeWarp-Regular.ttf b/fonts/warp/ttf/MachineMadeWarp-Regular.ttf index 046a01e..54d3538 100644 Binary files a/fonts/warp/ttf/MachineMadeWarp-Regular.ttf and b/fonts/warp/ttf/MachineMadeWarp-Regular.ttf differ diff --git a/fonts/warp/webfonts/MachineMadeWarp-Bold.woff2 b/fonts/warp/webfonts/MachineMadeWarp-Bold.woff2 index c940389..39198bb 100644 Binary files a/fonts/warp/webfonts/MachineMadeWarp-Bold.woff2 and b/fonts/warp/webfonts/MachineMadeWarp-Bold.woff2 differ diff --git a/fonts/warp/webfonts/MachineMadeWarp-BoldItalic.woff2 b/fonts/warp/webfonts/MachineMadeWarp-BoldItalic.woff2 index 55f156e..adabf3e 100644 Binary files a/fonts/warp/webfonts/MachineMadeWarp-BoldItalic.woff2 and b/fonts/warp/webfonts/MachineMadeWarp-BoldItalic.woff2 differ diff --git a/fonts/warp/webfonts/MachineMadeWarp-Regular.woff2 b/fonts/warp/webfonts/MachineMadeWarp-Regular.woff2 index 4de2e78..892fb42 100644 Binary files a/fonts/warp/webfonts/MachineMadeWarp-Regular.woff2 and b/fonts/warp/webfonts/MachineMadeWarp-Regular.woff2 differ diff --git a/proofs/warp/fidelity.json b/proofs/warp/fidelity.json index b4bc2de..12992ef 100644 --- a/proofs/warp/fidelity.json +++ b/proofs/warp/fidelity.json @@ -21,7 +21,7 @@ "16": { "max": 0.03938, "max_glyph": "underscore", - "mean": 0.00982, + "mean": 0.00983, "p95": 0.01643, "pass": true }, @@ -1699,10 +1699,10 @@ "128": 0.02046 }, "brevecomb.case": { - "16": 0.00722, - "32": 0.01218, - "64": 0.01606, - "128": 0.0169 + "16": 0.00774, + "32": 0.01233, + "64": 0.01654, + "128": 0.01813 }, "hungarumlautcomb": { "16": 0.01047, @@ -6781,16 +6781,16 @@ }, "glyphs": { "exclam": { - "16": 0.00628, - "32": 0.00663, - "64": 0.0074, - "128": 0.00769 + "16": 0.00634, + "32": 0.00669, + "64": 0.00748, + "128": 0.00771 }, "quotedbl": { - "16": 0.00991, - "32": 0.01192, - "64": 0.01289, - "128": 0.01321 + "16": 0.01003, + "32": 0.01203, + "64": 0.01285, + "128": 0.01323 }, "numbersign": { "16": 0.00579, @@ -6817,10 +6817,10 @@ "128": 0.00847 }, "quotesingle": { - "16": 0.01098, - "32": 0.01185, - "64": 0.01301, - "128": 0.0133 + "16": 0.01121, + "32": 0.01208, + "64": 0.01294, + "128": 0.01334 }, "parenleft": { "16": 0.00505, @@ -7003,10 +7003,10 @@ "128": 0.00879 }, "F": { - "16": 0.0064, - "32": 0.0067, + "16": 0.00639, + "32": 0.00668, "64": 0.00727, - "128": 0.00775 + "128": 0.00769 }, "G": { "16": 0.00741, @@ -7489,10 +7489,10 @@ "128": 0.01116 }, "sterling": { - "16": 0.0073, - "32": 0.00886, - "64": 0.00959, - "128": 0.0099 + "16": 0.00727, + "32": 0.00888, + "64": 0.00962, + "128": 0.00994 }, "yen": { "16": 0.00536, @@ -8107,10 +8107,10 @@ "128": 0.0101 }, "universal": { - "16": 0.01083, - "32": 0.01149, - "64": 0.01172, - "128": 0.01184 + "16": 0.01093, + "32": 0.01153, + "64": 0.01176, + "128": 0.01185 }, "existential": { "16": 0.00959, @@ -11473,10 +11473,10 @@ "128": 0.0101 }, "universal": { - "16": 0.01083, - "32": 0.01149, - "64": 0.01172, - "128": 0.01184 + "16": 0.01093, + "32": 0.01153, + "64": 0.01176, + "128": 0.01185 }, "existential": { "16": 0.00959, diff --git a/proofs/warp/sketch-compare.json b/proofs/warp/sketch-compare.json index 26485e9..da3e752 100644 --- a/proofs/warp/sketch-compare.json +++ b/proofs/warp/sketch-compare.json @@ -294,7 +294,7 @@ "C": 0.7934, "D": 0.7719, "E": 0.8016, - "F": 0.843, + "F": 0.8434, "G": 0.7972, "H": 0.7912, "I": 0.9375, @@ -331,7 +331,7 @@ ";": 0.9129, "!": 0.9881, "?": 0.8079, - "'": 0.9886, + "'": 0.9915, "’": 0.9115, "-": 0.8907, "–": 0.9456, diff --git a/proofs/warp/warp-charset-bold.png b/proofs/warp/warp-charset-bold.png index fb3fe2b..f786cf8 100644 Binary files a/proofs/warp/warp-charset-bold.png and b/proofs/warp/warp-charset-bold.png differ diff --git a/proofs/warp/warp-charset-bolditalic.png b/proofs/warp/warp-charset-bolditalic.png index af03a0e..39ce50b 100644 Binary files a/proofs/warp/warp-charset-bolditalic.png and b/proofs/warp/warp-charset-bolditalic.png differ diff --git a/proofs/warp/warp-charset-regular.png b/proofs/warp/warp-charset-regular.png index 5c717a8..775c85a 100644 Binary files a/proofs/warp/warp-charset-regular.png and b/proofs/warp/warp-charset-regular.png differ diff --git a/proofs/warp/warp-kerning.png b/proofs/warp/warp-kerning.png index 79ee6ce..e4311e5 100644 Binary files a/proofs/warp/warp-kerning.png and b/proofs/warp/warp-kerning.png differ diff --git a/proofs/warp/warp-latin-ext.png b/proofs/warp/warp-latin-ext.png index 06cd029..270094a 100644 Binary files a/proofs/warp/warp-latin-ext.png and b/proofs/warp/warp-latin-ext.png differ diff --git a/proofs/warp/warp-sketch-vs-font-bold.png b/proofs/warp/warp-sketch-vs-font-bold.png index f0e9868..796987c 100644 Binary files a/proofs/warp/warp-sketch-vs-font-bold.png and b/proofs/warp/warp-sketch-vs-font-bold.png differ diff --git a/proofs/warp/warp-smallcaps-figures.png b/proofs/warp/warp-smallcaps-figures.png index 75359f7..880420a 100644 Binary files a/proofs/warp/warp-smallcaps-figures.png and b/proofs/warp/warp-smallcaps-figures.png differ diff --git a/warp/CHANGELOG.md b/warp/CHANGELOG.md index d71182c..725ad64 100644 --- a/warp/CHANGELOG.md +++ b/warp/CHANGELOG.md @@ -35,6 +35,7 @@ ### Changed +- The build output no longer depends on the CPU path used by NumPy. `fonts/warp` and the fidelity, sketch and charset proofs in `proofs/warp` were rebuilt. The OCR report and the MATH image were not rebuilt and still come from the 28 October build. Most outlines change only in the last bits of their coordinates; the largest known change is in the Xi (Regular) outline, which moved by up to 2 font units because of the CPU-dependent curve fit. - g: the lower loop closes on the link (OCR read it as a comma). - f, ff: a firmer hook with a larger drop. fi fl: the bar stops short of the i and the l; the f of fl ends in its own drop. diff --git a/warp/STATUS.md b/warp/STATUS.md index 9c5c6c7..428b808 100644 --- a/warp/STATUS.md +++ b/warp/STATUS.md @@ -4,12 +4,54 @@ | --- | --- | | Family | machine-made Warp, machine-made Warp Display | | Version | 0.2 (font version 0.200) | -| Pull request | branch `warp/v0.2` (PR open for review) | +| Pull request | CPU build fix prepared for review | | Stage | Warp 0.2 built: 6 static styles (TTF hinted, OTF, WOFF2) with an OpenType MATH table | -| Gate now | Jac reviews the PR; the founder reviews the proofs by eye | +| Gate now | The founder reviews the rebuilt proof sheets by eye. The OCR gate runs on the hosted workflow. The MATH proof stays an open local check: it is not run in CI, and this machine cannot load `luaotfload` | | Next | Release 0.2 (tag `warp-v0.2.0`). Then 0.3 (variable font, branch `warp/v0.3`) | -| Last local run | 2026-10-28: build twice (byte-identical), fidelity (max 4.0 %), OCR gate, Fontspector 1.9.0 (0 FAIL, 0 ERROR), LuaLaTeX maths proof, site build, pytest (88 passed) | -| Last green CI | see the PR | +| Last local run | 2026-11-07: pytest (90 passed), 3 full builds, both CPU comparisons byte-identical, fidelity (max 4.016 %), Fontspector 1.9.0 (0 FAIL, 0 ERROR), proof sheets rebuilt except MATH; OCR and MATH checks blocked by missing tools | +| Last green CI | Pending for the CPU build fix | + +## CPU build check (2026-11-07) + +The outline fit uses products for powers, scalar math for distances and +angles, and a fixed order for sums. It does not use BLAS. The pen model +and the fit limits stay the same. + +The build is reproducible across the two tested CPU paths. A fresh build +with the default features and a fresh build with AVX-512 off each matched +all 18 rebuilt font files, byte for byte. This was checked on one machine. +The hosted CI run is still needed as a check on another machine. + +The outline fidelity gate passes: the maximum ink difference is 4.016 % +(limit 5 %). Fontspector 1.9.0 passes on all 12 TTF and OTF files, with +0 FAIL and 0 ERROR. The checks used the universal profile with network +checks off. JSON reports were written; Markdown export could not write +its template cache. + +The proof sheets were rebuilt. Two local checks remain open: Tesseract +is not installed, so the OCR gate could not run. LuaLaTeX cannot load +`luaotfload-main`, so the MATH proof could not be rebuilt. The old OCR +report and MATH image stay in place; they are not evidence for this run. + +Two tests use separate processes with the default CPU features and with +AVX-512 off. One checks curve points, the fit, and corner angles. The +other builds the small glyph set needed to check the Greek capital Xi: +Xi, .notdef, and o for hinting. It compares every byte of the hinted TTF, +OTF, and WOFF2 files. Both tests fail with the old arithmetic and pass +with the fixed code. + +From the repo root, with the build tools on PATH, run: + +```sh +RAYON_NUM_THREADS=2 WARP_JOBS=1 warp/scripts/build.sh +NPY_DISABLE_CPU_FEATURES="X86_V4 AVX512_ICL AVX512_SKX AVX512F" \ + RAYON_NUM_THREADS=2 WARP_JOBS=1 warp/scripts/build.sh +RAYON_NUM_THREADS=2 python -m pytest warp/tests -k cpu_features +``` + +CI checks each build against the committed fonts. Any byte change fails +the check. GNU cmp has no recursive mode: for a local folder check, +compare the file lists, then run `cmp` on each pair of files. ## Proven (local run, 2026-10-28) @@ -25,7 +67,7 @@ - Fontspector 1.9.0, universal profile: 0 FAIL, 0 ERROR on the 6 TTF and the 6 OTF (accepted WARNs below). - Outline fidelity (`proofs/warp/fidelity.json`): every drawn glyph of - every style against an independent fill of its pen sweep; FIDMAX. + every style against an independent fill of its pen sweep. Limit 5 %. - OCR readability gate (`proofs/warp/readability.json`): see below. - LuaLaTeX and unicode-math set a page of formulas with the MATH table @@ -36,6 +78,8 @@ ## Readability (OCR) +Results of 2026-10-28, from the 0.2 build before the CPU-independent rebuild of 2026-11-07. The hosted workflow reruns the gate on every change. + | Style | Warp 0.2 CER | Warp 0.1 CER | DejaVu Serif | Limit | Result | | --- | --- | --- | --- | --- | --- | | Regular | 0.0018 | 0.0234 | 0.0012 | 0.0312 | pass | diff --git a/warp/loom/outline.py b/warp/loom/outline.py index 5dfcc35..88ecc05 100644 --- a/warp/loom/outline.py +++ b/warp/loom/outline.py @@ -21,6 +21,7 @@ from __future__ import annotations import math +from itertools import accumulate import numpy as np import pathops @@ -33,6 +34,35 @@ LINE_TOL = 0.35 # a run within this of its chord is a line +# Use scalar libm and an explicit addition order wherever NumPy can select +# a SIMD or BLAS kernel. Keep ordinary elementwise arithmetic as arrays. +def _hypot(x, y): + """Elementwise length with the same scalar operation on every CPU path.""" + x, y = np.broadcast_arrays(x, y) + return np.fromiter( + (math.hypot(float(a), float(b)) for a, b in zip(x.flat, y.flat)), + dtype=float, count=x.size, + ).reshape(x.shape) + + +def _sum(values): + """Sum in row-major order without SIMD, BLAS, or pairwise reductions.""" + total = 0.0 + for value in values.flat: + total += float(value) + return total + + +def _cumsum(values): + """Prefix sums in input order, using scalar float additions.""" + return np.fromiter(accumulate(map(float, values)), dtype=float, count=len(values)) + + +def _dot2(a, b): + """Two-dimensional dot products with separate multiplies and addition.""" + return a[..., 0] * b[..., 0] + a[..., 1] * b[..., 1] + + # ------------------------------------------------------------------ union def union(polys) -> pathops.Path: @@ -71,7 +101,7 @@ def path_polygons(path: pathops.Path) -> list[np.ndarray]: def signed_area(a: np.ndarray) -> float: x, y = a[:, 0], a[:, 1] - return 0.5 * float(np.dot(x, np.roll(y, -1)) - np.dot(np.roll(x, -1), y)) + return 0.5 * (_sum(x * np.roll(y, -1)) - _sum(np.roll(x, -1) * y)) # -------------------------------------------------------------- cleanup @@ -79,10 +109,10 @@ def signed_area(a: np.ndarray) -> float: def dedupe(a: np.ndarray, eps: float = 0.05) -> np.ndarray: keep = [0] for i in range(1, len(a)): - if np.hypot(*(a[i] - a[keep[-1]])) > eps: + if math.hypot(*(a[i] - a[keep[-1]])) > eps: keep.append(i) a = a[keep] - if len(a) > 2 and np.hypot(*(a[0] - a[-1])) <= eps: + if len(a) > 2 and math.hypot(*(a[0] - a[-1])) <= eps: a = a[:-1] return a @@ -98,7 +128,7 @@ def drop_collinear(a: np.ndarray, eps: float = 1e-3) -> np.ndarray: d1, d2 = p1 - p0, p2 - p1 cross = d1[0] * d2[1] - d1[1] * d2[0] dot = d1[0] * d2[0] + d1[1] * d2[1] - if abs(cross) <= eps * np.hypot(*d1) * np.hypot(*d2) and dot > 0: + if abs(cross) <= eps * math.hypot(*d1) * math.hypot(*d2) and dot > 0: changed = True continue keep.append(i) @@ -110,9 +140,12 @@ def turning(a: np.ndarray) -> np.ndarray: """Signed turning angle at each vertex, in degrees.""" d_in = a - np.roll(a, 1, axis=0) d_out = np.roll(a, -1, axis=0) - a - ang = np.arctan2(d_in[:, 0] * d_out[:, 1] - d_in[:, 1] * d_out[:, 0], - (d_in * d_out).sum(axis=1)) - return np.degrees(ang) + ang = d_in[:, 0] * d_out[:, 1] - d_in[:, 1] * d_out[:, 0] + dot = _dot2(d_in, d_out) + return np.fromiter( + (math.degrees(math.atan2(float(y), float(x))) for y, x in zip(ang, dot)), + dtype=float, count=len(a), + ) def corners(a: np.ndarray) -> list[int]: @@ -125,7 +158,7 @@ def corners(a: np.ndarray) -> list[int]: """ n = len(a) th = turning(a) - seg = np.hypot(*(np.roll(a, -1, axis=0) - a).T) + seg = _hypot(*(np.roll(a, -1, axis=0) - a).T) cand = [] half = CORNER_MERGE / 2 for i in range(n): @@ -155,8 +188,8 @@ def corners(a: np.ndarray) -> list[int]: if not cand: return [] # merge close candidates: keep the strongest of each cluster - pos = np.concatenate([[0], np.cumsum(seg)])[:n] - total = float(seg.sum()) + pos = np.concatenate([[0], _cumsum(seg)])[:n] + total = _sum(seg) cand.sort() clusters = [[cand[0]]] for c in cand[1:]: @@ -174,7 +207,9 @@ def corners(a: np.ndarray) -> list[int]: def _bez(ctrl, t): t = np.asarray(t)[:, None] m = 1 - t - return m ** 3 * ctrl[0] + 3 * m * m * t * ctrl[1] + 3 * m * t * t * ctrl[2] + t ** 3 * ctrl[3] + # Products, not ** 3: NumPy computes power with a different (AVX-512) code + # path on some CPUs, and its last-bit differences change the curve fit. + return m * m * m * ctrl[0] + 3 * m * m * t * ctrl[1] + 3 * m * t * t * ctrl[2] + t * t * t * ctrl[3] def _bez_d1(ctrl, t): @@ -190,28 +225,28 @@ def _bez_d2(ctrl, t): def _chord_params(pts): - d = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(pts, axis=0).T))]) + d = np.concatenate([[0.0], _cumsum(_hypot(*np.diff(pts, axis=0).T))]) return d / d[-1] if d[-1] > 0 else d def _unit(v): - n = np.hypot(*v) + n = math.hypot(*v) return v / n if n > 0 else v def _generate(pts, u, t1, t2): p0, p3 = pts[0], pts[-1] - A1 = t1[None, :] * (3 * (1 - u) ** 2 * u)[:, None] - A2 = t2[None, :] * (3 * (1 - u) * u ** 2)[:, None] - C00 = (A1 * A1).sum() - C01 = (A1 * A2).sum() - C11 = (A2 * A2).sum() + A1 = t1[None, :] * (3 * (1 - u) * (1 - u) * u)[:, None] + A2 = t2[None, :] * (3 * (1 - u) * u * u)[:, None] + C00 = _sum(A1 * A1) + C01 = _sum(A1 * A2) + C11 = _sum(A2 * A2) base = _bez(np.array([p0, p0, p3, p3]), u) tmp = pts - base - X0 = (A1 * tmp).sum() - X1 = (A2 * tmp).sum() + X0 = _sum(A1 * tmp) + X1 = _sum(A2 * tmp) det = C00 * C11 - C01 * C01 - seg = np.hypot(*(p3 - p0)) + seg = math.hypot(*(p3 - p0)) if abs(det) > 1e-12: a1 = (X0 * C11 - X1 * C01) / det a2 = (C00 * X1 - C01 * X0) / det @@ -224,7 +259,7 @@ def _generate(pts, u, t1, t2): def _max_error(pts, ctrl, u): - d = np.hypot(*(_bez(ctrl, u) - pts).T) + d = _hypot(*(_bez(ctrl, u) - pts).T) i = int(np.argmax(d)) return float(d[i]), i @@ -233,8 +268,8 @@ def _reparam(pts, ctrl, u): q = _bez(ctrl, u) d1 = _bez_d1(ctrl, u) d2 = _bez_d2(ctrl, u) - num = ((q - pts) * d1).sum(axis=1) - den = (d1 * d1).sum(axis=1) + ((q - pts) * d2).sum(axis=1) + num = _dot2(q - pts, d1) + den = _dot2(d1, d1) + _dot2(q - pts, d2) with np.errstate(divide="ignore", invalid="ignore"): nu = np.where(np.abs(den) > 1e-12, u - num / den, u) nu = np.clip(nu, 0, 1) @@ -251,17 +286,17 @@ def _true_error(pts, ctrl, samples=24): tt = np.linspace(0, 1, samples * 4 + 1) cp = _bez(ctrl, tt) # vertex -> curve - d1 = np.min(np.hypot(pts[:, None, 0] - cp[None, :, 0], pts[:, None, 1] - cp[None, :, 1]), axis=1).max() + d1 = np.min(_hypot(pts[:, None, 0] - cp[None, :, 0], pts[:, None, 1] - cp[None, :, 1]), axis=1).max() # curve -> polyline segments a, b = pts[:-1], pts[1:] ab = b - a - L2 = (ab * ab).sum(axis=1) + L2 = _dot2(ab, ab) L2[L2 == 0] = 1 best = np.full(len(cp), np.inf) for k in range(len(a)): - t = np.clip(((cp - a[k]) @ ab[k]) / L2[k], 0, 1) + t = np.clip(_dot2(cp - a[k], ab[k]) / L2[k], 0, 1) proj = a[k] + t[:, None] * ab[k] - best = np.minimum(best, np.hypot(*(cp - proj).T)) + best = np.minimum(best, _hypot(*(cp - proj).T)) return max(float(d1), float(best.max())) @@ -298,7 +333,7 @@ def fit_cubics(pts, t1, t2, tol=TOL, depth=0): def _is_line(pts, tol=LINE_TOL) -> bool: a, b = pts[0], pts[-1] ab = b - a - L = np.hypot(*ab) + L = math.hypot(*ab) if L == 0: return False d = np.abs((pts[:, 0] - a[0]) * ab[1] - (pts[:, 1] - a[1]) * ab[0]) / L @@ -309,7 +344,7 @@ def _tangent_out(run): """Direction leaving the start of a run (first 3 units of arc).""" acc = 0.0 for k in range(1, len(run)): - acc += np.hypot(*(run[k] - run[k - 1])) + acc += math.hypot(*(run[k] - run[k - 1])) if acc >= 3.0: return _unit(run[k] - run[0]) return _unit(run[-1] - run[0]) @@ -358,7 +393,7 @@ def _split_at(c, t): def _arc_len(c): q = _bez(c, np.linspace(0, 1, 9)) - return float(np.hypot(*np.diff(q, axis=0).T).sum()) + return _sum(_hypot(*np.diff(q, axis=0).T)) def fit_contour(a: np.ndarray, tol=TOL) -> list: @@ -404,7 +439,7 @@ def fit_contour(a: np.ndarray, tol=TOL) -> list: def _seg_len(s): - return float(np.hypot(*(s[2] - s[1]))) if s[0] == "line" else _arc_len(s[1]) + return float(math.hypot(*(s[2] - s[1]))) if s[0] == "line" else _arc_len(s[1]) def merge_short(segs: list) -> list: diff --git a/warp/tests/test_warp.py b/warp/tests/test_warp.py index c570444..4f9fee7 100644 --- a/warp/tests/test_warp.py +++ b/warp/tests/test_warp.py @@ -7,6 +7,10 @@ import json import math +import os +import subprocess +import sys +import textwrap from pathlib import Path import numpy as np @@ -668,3 +672,92 @@ def test_build_is_deterministic(): assert [[(p.x, p.y, p.segmentType) for p in c] for c in ga.contours] == \ [[(p.x, p.y, p.segmentType) for p in c] for c in gb.contours] assert a.kerning == b.kerning and a.features.text == b.features.text + + +def _cpu_env(disable): + env = {k: v for k, v in os.environ.items() if k != "NPY_DISABLE_CPU_FEATURES"} + if disable: + env["NPY_DISABLE_CPU_FEATURES"] = "X86_V4 AVX512_ICL AVX512_SKX AVX512F" + return env + + +def test_curve_fit_does_not_depend_on_cpu_features(): + """NumPy uses other code (AVX-512) for some functions on some CPUs. The + fitted outlines, and so the fonts, must not change with it: the CI + machines differ, and the committed fonts are checked against a fresh build.""" + code = ( + "import numpy as np\n" + "from loom import outline as O\n" + "t = np.random.default_rng(3).random(4000)\n" + "ctrl = np.array([[0, 0], [37.3, 211.9], [402.1, 98.7], [455.5, 12.4]])\n" + "pts = O._bez(ctrl, np.linspace(0, 1, 60))\n" + "u = O._chord_params(pts)\n" + "fit = O._generate(pts, u, O._unit(ctrl[1] - ctrl[0]), O._unit(ctrl[2] - ctrl[3]))\n" + "print(O._bez(ctrl, t).tobytes().hex(), fit.tobytes().hex())\n" + # Corner detection also uses CPU-dispatched trigonometry. This sample + # exercises that path even when cubic evaluation happens to agree. + "polygon = np.random.default_rng(3).random((4000, 2)) * 500\n" + "print(O.turning(polygon).tobytes().hex())\n" + ) + + def run(disable): + r = subprocess.run([sys.executable, "-c", code], cwd=WARP, + env=_cpu_env(disable), capture_output=True, text=True, check=True) + assert r.returncode == 0, r.stderr + return r.stdout + + assert run(False) == run(True) + + +def test_font_bytes_do_not_depend_on_cpu_features(tmp_path): + """Compile the Xi regression with .notdef and the o needed for hinting. + + Use fresh processes: NumPy chooses its CPU kernels at import time. Check + TTF, OTF and WOFF2 bytes, including TTF hinting and WOFF2 compression. + """ + code = textwrap.dedent("""\ + import subprocess + import sys + from pathlib import Path + import ufoLib2 + from fontTools.ttLib import TTFont + from ufo2ft import compileOTF, compileTTF + from loom import build as B, charset as C, outline as O + + out = Path(sys.argv[1]) + font = ufoLib2.Font() + B.set_info(font, B.STYLES["Regular"]) + names = [".notdef", C.glyph_name("Ξ"), "o"] + font.lib["public.glyphOrder"] = names + for name in names: + glyph = font.newGlyph(name) + glyph.unicodes = list(C.BY_NAME[name].unicodes) + drawn = B.drawn("Regular", name) + glyph.width = B.rnd(drawn.adv) + O.draw(drawn.contours, glyph.getPen()) + ascent, descent = B.win_metrics([font]) + font.info.openTypeOS2WinAscent = ascent + font.info.openTypeOS2WinDescent = descent + compileTTF(font, removeOverlaps=False).save(out / "raw.ttf") + compileOTF(font, removeOverlaps=False).save(out / "font.otf") + subprocess.run([sys.executable, "-m", "ttfautohint", "--no-info", + str(out / "raw.ttf"), str(out / "font.ttf")], check=True) + with TTFont(out / "font.ttf") as ttf: + ttf.save(out / "font.ttf") + ttf.flavor = "woff2" + ttf.save(out / "font.woff2") + (out / "raw.ttf").unlink() + """) + folders = [tmp_path / "default", tmp_path / "no-avx512"] + for disable, folder in zip((False, True), folders): + folder.mkdir() + env = _cpu_env(disable) + env["SOURCE_DATE_EPOCH"] = "1793059200" + result = subprocess.run([sys.executable, "-c", code, str(folder)], + cwd=WARP, env=env, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + for name in ("font.ttf", "font.otf", "font.woff2"): + # Compare every byte, but never dump a font into a failure report. + assert (folders[0] / name).stat().st_size > 0 + assert (folders[0] / name).read_bytes() == (folders[1] / name).read_bytes(), \ + f"CPU-dependent bytes in {name}"