diff --git a/.github/workflows/heddle.yml b/.github/workflows/heddle.yml index e2523c7..b0058b3 100644 --- a/.github/workflows/heddle.yml +++ b/.github/workflows/heddle.yml @@ -95,7 +95,7 @@ jobs: if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: heddle-0.1 + name: heddle-0.2 path: | fonts/heddle/ proofs/heddle/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4275c00..21edf09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ Each family keeps its own changelog: Release tags: `weft-vX.Y.Z`, `sans-vX.Y.Z`, `heddle-vX.Y.Z`, `warp-vX.Y.Z`, `site-vX.Y.Z`. +## 2026-10-28: Heddle 0.2 + +- machine-made Heddle 0.2.0 (font version 0.200): Latin Extended-A, + Ș ș Ț ț, new symbols and fractions, lighter marks, open counters at + Black, masters at wdth 85, hinted statics. See `heddle/CHANGELOG.md`. +- Site: the Heddle page and the download page link + `heddle-v0.2.0`. + ## 2026-10-27: Warp 0.1 - machine-made Warp 0.1.0 (font version 0.100): machine-made Warp diff --git a/STATUS.md b/STATUS.md index 4971a3e..6687b82 100644 --- a/STATUS.md +++ b/STATUS.md @@ -8,7 +8,7 @@ | Visibility | public after the 0.1 release (founder approved) | | Stage | released 0.1: machine-made Weft 0.1.0, machine-made Sans 0.1.0, specimen site 0.1.0, machine-made Heddle 0.1.0. machine-made Warp 0.1 built on branch `warp/v0.1` | | Gate now | Warp: Jac reviews the PR from `warp/v0.1`; the founder reviews the proofs; then the tags `warp-v0.1.0` and a new `site-v*` | -| Next task | Warp 0.2: the OpenType MATH table; Warp 0.3: the variable font (see `warp/STATUS.md`). Heddle 0.2 (see `heddle/STATUS.md`). Weft: polish (look-alikes l/1, I/1, O/0; heavy accented capitals), width axis, Thread style. Sans 0.2: redraw in the style of Weft and match Weft's x-height (see `sans/STATUS.md`). npm packages for both families. | +| Next task | Warp 0.2: the OpenType MATH table; Warp 0.3: the variable font (see `warp/STATUS.md`). Heddle 0.2 built on branch `heddle/v0.2`, in review (see `heddle/STATUS.md`). Weft: polish (look-alikes l/1, I/1, O/0; heavy accented capitals), width axis, Thread style. Sans 0.2: redraw in the style of Weft and match Weft's x-height (see `sans/STATUS.md`). npm packages for both families. | | Blocked by | none | | Last green CI | none yet (first runs are on the release PR). Local end-to-end run of all steps on 2026-10-26: green | | Owner model | to assign | diff --git a/fonts/heddle/ttf/MachineMadeHeddle-Black.ttf b/fonts/heddle/ttf/MachineMadeHeddle-Black.ttf index 00912bc..96f9874 100644 Binary files a/fonts/heddle/ttf/MachineMadeHeddle-Black.ttf and b/fonts/heddle/ttf/MachineMadeHeddle-Black.ttf differ diff --git a/fonts/heddle/ttf/MachineMadeHeddle-Bold.ttf b/fonts/heddle/ttf/MachineMadeHeddle-Bold.ttf index a22aa4e..12c34fa 100644 Binary files a/fonts/heddle/ttf/MachineMadeHeddle-Bold.ttf and b/fonts/heddle/ttf/MachineMadeHeddle-Bold.ttf differ diff --git a/fonts/heddle/ttf/MachineMadeHeddle-CompressedBold.ttf b/fonts/heddle/ttf/MachineMadeHeddle-CompressedBold.ttf index e48ead1..cb2d39a 100644 Binary files a/fonts/heddle/ttf/MachineMadeHeddle-CompressedBold.ttf and b/fonts/heddle/ttf/MachineMadeHeddle-CompressedBold.ttf differ diff --git a/fonts/heddle/ttf/MachineMadeHeddle-ExtendedBold.ttf b/fonts/heddle/ttf/MachineMadeHeddle-ExtendedBold.ttf index 05dd562..50b6797 100644 Binary files a/fonts/heddle/ttf/MachineMadeHeddle-ExtendedBold.ttf and b/fonts/heddle/ttf/MachineMadeHeddle-ExtendedBold.ttf differ diff --git a/fonts/heddle/ttf/MachineMadeHeddle-ExtraBold.ttf b/fonts/heddle/ttf/MachineMadeHeddle-ExtraBold.ttf index 809e88b..fa4d018 100644 Binary files a/fonts/heddle/ttf/MachineMadeHeddle-ExtraBold.ttf and b/fonts/heddle/ttf/MachineMadeHeddle-ExtraBold.ttf differ diff --git a/fonts/heddle/ttf/MachineMadeHeddle-ExtraLight.ttf b/fonts/heddle/ttf/MachineMadeHeddle-ExtraLight.ttf index 2119e14..d2a099b 100644 Binary files a/fonts/heddle/ttf/MachineMadeHeddle-ExtraLight.ttf and b/fonts/heddle/ttf/MachineMadeHeddle-ExtraLight.ttf differ diff --git a/fonts/heddle/ttf/MachineMadeHeddle-Light.ttf b/fonts/heddle/ttf/MachineMadeHeddle-Light.ttf index f8b366c..ab7fa36 100644 Binary files a/fonts/heddle/ttf/MachineMadeHeddle-Light.ttf and b/fonts/heddle/ttf/MachineMadeHeddle-Light.ttf differ diff --git a/fonts/heddle/ttf/MachineMadeHeddle-Medium.ttf b/fonts/heddle/ttf/MachineMadeHeddle-Medium.ttf index 1ef4eb8..851c925 100644 Binary files a/fonts/heddle/ttf/MachineMadeHeddle-Medium.ttf and b/fonts/heddle/ttf/MachineMadeHeddle-Medium.ttf differ diff --git a/fonts/heddle/ttf/MachineMadeHeddle-Regular.ttf b/fonts/heddle/ttf/MachineMadeHeddle-Regular.ttf index d134af4..49a91ac 100644 Binary files a/fonts/heddle/ttf/MachineMadeHeddle-Regular.ttf and b/fonts/heddle/ttf/MachineMadeHeddle-Regular.ttf differ diff --git a/fonts/heddle/ttf/MachineMadeHeddle-SemiBold.ttf b/fonts/heddle/ttf/MachineMadeHeddle-SemiBold.ttf index f9f3438..258df77 100644 Binary files a/fonts/heddle/ttf/MachineMadeHeddle-SemiBold.ttf and b/fonts/heddle/ttf/MachineMadeHeddle-SemiBold.ttf differ diff --git a/fonts/heddle/ttf/MachineMadeHeddle-Thin.ttf b/fonts/heddle/ttf/MachineMadeHeddle-Thin.ttf index 7455fe1..bd1290e 100644 Binary files a/fonts/heddle/ttf/MachineMadeHeddle-Thin.ttf and b/fonts/heddle/ttf/MachineMadeHeddle-Thin.ttf differ diff --git a/fonts/heddle/variable/MachineMadeHeddle[wdth,wght].ttf b/fonts/heddle/variable/MachineMadeHeddle[wdth,wght].ttf index fa5439c..68880d6 100644 Binary files a/fonts/heddle/variable/MachineMadeHeddle[wdth,wght].ttf and b/fonts/heddle/variable/MachineMadeHeddle[wdth,wght].ttf differ diff --git a/fonts/heddle/webfonts/MachineMadeHeddle-Bold.woff2 b/fonts/heddle/webfonts/MachineMadeHeddle-Bold.woff2 index 15d36db..850d976 100644 Binary files a/fonts/heddle/webfonts/MachineMadeHeddle-Bold.woff2 and b/fonts/heddle/webfonts/MachineMadeHeddle-Bold.woff2 differ diff --git a/fonts/heddle/webfonts/MachineMadeHeddle-Regular.woff2 b/fonts/heddle/webfonts/MachineMadeHeddle-Regular.woff2 index 503ef0d..c445193 100644 Binary files a/fonts/heddle/webfonts/MachineMadeHeddle-Regular.woff2 and b/fonts/heddle/webfonts/MachineMadeHeddle-Regular.woff2 differ diff --git a/fonts/heddle/webfonts/MachineMadeHeddle[wdth,wght].woff2 b/fonts/heddle/webfonts/MachineMadeHeddle[wdth,wght].woff2 index 1977e98..0a0a357 100644 Binary files a/fonts/heddle/webfonts/MachineMadeHeddle[wdth,wght].woff2 and b/fonts/heddle/webfonts/MachineMadeHeddle[wdth,wght].woff2 differ diff --git a/heddle/BRIEF.md b/heddle/BRIEF.md index 51e4a03..5d067c9 100644 --- a/heddle/BRIEF.md +++ b/heddle/BRIEF.md @@ -35,6 +35,36 @@ Heddle exist, none in the font market. | Software named heddle: an AI-native version control CLI (MacPorts port `heddle`), and `heddle-mcp` on PyPI. | [ports.macports.org/port/heddle](https://ports.macports.org/port/heddle/), [socket.dev/pypi/package/heddle-mcp](https://socket.dev/pypi/package/heddle-mcp) | Low for a typeface. Not in the font market. | | Trademarks (web): Steel Heddle Manufacturing Company holds marks for loom parts (textile machinery). No "HEDDLE" mark for fonts or typefaces found. | [trademark.justia.com/owners/steel-heddle-manufacturing-company-239051](https://trademark.justia.com/owners/steel-heddle-manufacturing-company-239051), [gleanmark.com/search?q=HEDDLE](https://gleanmark.com/search?q=HEDDLE) | Low. Weaving equipment, not type. The pages did not load in full; not a register search. | +## Register search: "HEDDLE" (2026-10-28, for 0.2) + +Result: **no HEDDLE mark for fonts, typefaces or software found** in +what the web search reached. The two marks found are for loom parts and +for textile decor. The family name stays "machine-made Heddle"; this +search does not rename it. + +| Register | Finding | Link | Risk | +| --- | --- | --- | --- | +| USPTO (via Justia, a mirror of USPTO data) | HEDDLE & CO., serial 98277502, filed 2023-11-20 by Linger in the Gap, for household decor and garden ornaments made of textiles. Status not shown on the page. | [trademarks.justia.com/owners/linger-in-the-gap-5709250](https://trademarks.justia.com/owners/linger-in-the-gap-5709250) | Low. Other goods (textiles, decor), not class 9, 16 or 42. | +| USPTO (via Justia) | STEEL HEDDLE (Steel Heddle Manufacturing Company), serials 76330022 and 75982481: loom parts. | [trademark.justia.com/763/30/steel-76330022.html](https://trademark.justia.com/763/30/steel-76330022.html), [trademarks.justia.com/759/82/steel-75982481.html](https://trademarks.justia.com/759/82/steel-75982481.html) | Low. Textile machinery. | +| USPTO (web search) | Other "heddle" hits are patents for loom heddles (US 7185679, 7017620, 5152325 and more), not marks. A near name, HEDDON (reg. 0507613, fishing tackle, class 16 in the old system), is a different word. | [trademarkregistration.app/Trademark/Details/71534141](https://trademarkregistration.app/Trademark/Details/71534141) | None. | +| EUIPO (web search) | No EU trade mark HEDDLE found. The hits are European patents for loom heddles (EP 1989346, EP 2422004 and others). | [data.epo.org/gpi/EP1989346B1](https://data.epo.org/gpi/EP1989346B1) | Low. | +| Swissreg (web search) | No Swiss mark HEDDLE found. Hits are Swiss patents for heddles ("Weblitze", CH 468489, CH 423661). | [patents.google.com/patent/CH468489A](https://patents.google.com/patent/CH468489A/en) | Low. | +| Font-name aggregator (GleanMark) | The search page for HEDDLE loaded without results in the text the tool could read. | [gleanmark.com/search?q=HEDDLE](https://gleanmark.com/search?q=HEDDLE) | Unknown; see limits. | + +Limits of the register search: + +* The register sites themselves (tmsearch.uspto.gov, euipo.europa.eu + eSearch, swissreg.ch, branddb.wipo.int) were not reachable from the + build machine (the network proxy refused the connection). The search + used WebSearch and pages that mirror register data (Justia). It is + not a clearance search. +* Web search finds marks with web pages; a pending or little-known + mark can be missing. Before a commercial use of "Heddle" alone, a + person should search the registers directly for HEDDLE in classes 9, + 16 and 42. +* The shipped name is "machine-made Heddle", and the OFL Reserved Font + Name is "machine-made". + Limits of this check: * A web search, not a trademark clearance. Before a public release, do @@ -63,7 +93,7 @@ Limits of this check: | Typo / hhea ascender | 950 | | Typo / hhea descender | -250 | | Line gap | 0 (line height 1200) | -| win ascent / descent | from the bounds of all masters (1051 / 295) | +| win ascent / descent | from the bounds of all masters (0.2: 950 / 296; 0.1: 1051 / 295) | | Underline position / thickness | -120 / 71 | Stroke rules (from the sketch engine): @@ -119,6 +149,54 @@ so a cut always ends in a horizontal (or vertical) terminal. When a counter closes (heavy weights, small hooks), the inner contour shrinks to a point at the centre, as the sketch drew no counter there. +## 0.2 changes to the rules + +* Masters: 16, at wght 100, 400, 700, 900 x wdth 70, 85, 100, 140. The + wdth 85 masters cut the drift from the width floor near wdth 85. +* Tabular figure width: the sketch value at every master width (0.8 at + 70, 0.85 at 85, 1 at 100, 1.25 at 140), linear between them. +* Minimum counter rule (`geom.counter_min`): a ring keeps an inner + radius of at least 0.25 S, at most 38 units (clamped per weight). The + ring gets thinner to keep it, never below 45 % of its stroke. Bowls + with bars (`bowl_d`) give the bars the same strokes. +* Hooks (`glyphs.hook_span`: f, t, j, ſ, ŋ, Ŋ, £): the visible counter + between stem and terminal is at least two minimum radii. The hook + ring gets wider (j reaches left of its box at heavy weights), then the + terminal gets thinner. Nothing changes at Regular. +* Marks: stroke `0.62 S + 12`, height `140 + 0.2 S` (lowercase) and + 0.72 of that (capitals), gap `40 + 0.22 S` and `30 + 0.16 S`. + Capital marks stay below the typo ascender (950). ".narrow" marks on + dotless i, I, l and dotless j are at most `S + 1.45 sidebearings` + wide, so ï Ï Ĩ ĺ keep the advance of their base. +* Static outlines: union with skia-pathops, round to integers, drop + degenerate segments, split self-touching contours into loops, wind + each loop by its nesting depth, then check against the nonzero fill + of the primitives and check that no overlap is left. No fallbacks. +* Hinting: static TTF with ttfautohint 1.8.4 (ttfautohint-py 0.6.1), + no version string, head dates kept, so the build stays + byte-reproducible. The variable font is not hinted. + +## Glyph set (0.2) + +398 glyphs, 338 code points (336 visible characters, space, no-break +space). 0.2 adds to the 0.1 set: + +| Set | Characters | +| --- | --- | +| Latin Extended-A | all of U+0100 to U+017F, as components where Unicode decomposes (ogonek, caron, breve, macron, dot above, double acute, comma below, cedilla); drawn: Đ đ (Đ shares the glyph of Ð), Ħ ħ, Ŧ ŧ, Ł ł, ĸ, Ŋ ŋ, ſ; rows of glyphs: IJ ij ʼn; middle dot: Ŀ ŀ | +| Romanian | Ș ș Ț ț (comma below) | +| Base | ȷ (dotless j, for ĵ) | +| Symbols | `¢ £ ¥ § ¶ ± × ÷ −`, fraction slash `⁄`, `½ ¼ ¾` | +| Spacing marks | `¨ ¯ ´ ¸ ˆ ˇ ˘ ˙ ˚ ˛ ˜ ˝` | +| Unencoded | numerators and denominators (`zero.numr` ... `nine.dnom`), marks: macron, breve, dot above, caron, double acute, ogonek, comma below, turned comma above (ģ), vertical caron (ď ľ ť Ľ), and .narrow forms | + +Features: `frac` (figures before a slash become numerators, after it +denominators; the slash becomes the fraction slash), `numr`, `dnom`, +`kern`. + +Comma below, not cedilla, for Ģ Ķ ķ Ļ ļ Ņ ņ Ŗ ŗ (Latvian); ģ has a +turned comma above. Ş ş Ţ ţ keep the cedilla (Turkish; Unicode). + ## Glyph set (0.1) 197 glyphs, 184 code points (182 visible characters, space, no-break @@ -156,7 +234,7 @@ weight. | Step | Scope | Gate | | --- | --- | --- | | 1 | Port the engine to `loom/` (Python). Analytic cut rings, fixed topology. | Same advances as the sketch engine at every master (test with node). | -| 2 | 12 masters: wght 100, 400, 700, 900 x wdth 70, 100, 140. Variable TTF with overlaps (overlap flags set). | `varLib.interpolatable` clean; drift report. | +| 2 | 12 masters (0.2: 16, with wdth 85): wght 100, 400, 700, 900 x wdth 70, 100, 140. Variable TTF with overlaps (overlap flags set). | `varLib.interpolatable` clean; drift report. | | 3 | Statics from the masters (same interpolation), overlaps removed with skia-pathops, every union checked against a nonzero fill. | Fontspector universal: no FAIL. | | 4 | Rest of ASCII, Latin-1 letters, marks, symbols, kerning. | Glyph and kerning tests. | | 5 | Proofs, CI, release job, site page. | **Founder reviews the proofs by eye.** | diff --git a/heddle/CHANGELOG.md b/heddle/CHANGELOG.md index a0e1d93..2ee0c72 100644 --- a/heddle/CHANGELOG.md +++ b/heddle/CHANGELOG.md @@ -1,5 +1,42 @@ # Heddle changelog +## 0.200 (Heddle 0.2) + +### Added + +- Latin Extended-A (U+0100 to U+017F) and Ș ș Ț ț. Accented letters + are components; Đ đ Ħ ħ Ŧ ŧ Ł ł ĸ Ŋ ŋ ſ and the dotless j are drawn. + New marks: macron, breve, dot above, caron, double acute, ogonek, + comma below, turned comma above, vertical caron. +- `¢ £ ¥ § ¶ ± × ÷ −`, the fraction slash, `½ ¼ ¾`, numerators and + denominators, and the spacing marks `¨ ¯ ´ ¸ ˆ ˇ ˘ ˙ ˚ ˛ ˜ ˝`. + 398 glyphs, 338 code points. +- Features `frac`, `numr` and `dnom`. +- Kerning for lowercase pairs and for figures against punctuation. +- Masters at wdth 85: 16 masters. +- Hinting: the static TTF are hinted with ttfautohint 1.8.4 + (ttfautohint-py 0.6.1, pinned). +- `tools/readability.py`: an OCR check against 0.1 and DejaVu Sans. +- Proofs: `heddle-latin-ext.png`, `heddle-symbols.png` and before/after + sheets for the marks, the counters and the drift. + +### Changed + +- Marks are lighter and smaller. Narrow bases (ı I l ȷ) get narrow + marks, so ï Ï Ĩ ĺ keep the advance of their base. Capital marks stay + below the typo ascender; win ascent is now 950 (was 1051). +- Minimum counter rule: rings keep an open counter at Black (inner + radius at least 0.25 S, at most 38 units). The hooks of f, t, j (and + ſ ŋ Ŋ £) get wider at heavy weights so their counters stay open. +- ß, ð, @, ©, ®, % redrawn with the counter rule (© ® with lighter + inner letters; ð's ascender starts inside the bowl). +- Tabular figures at wdth 85 are 0.85 of their width (the sketch + value); 0.1 gave 0.9. +- Overlap removal for the statics: union, snap to integers, split + self-touching contours, wind by nesting depth, check. The fallbacks + of 0.1 (jitter, unsimplified rounded paths) are gone. +- The kerning test checks every member of every class. + ## 0.100 (Heddle 0.1) Sketched and first built as "Shuttle"; renamed to Heddle before the diff --git a/heddle/RELEASE-NOTES-0.2.md b/heddle/RELEASE-NOTES-0.2.md new file mode 100644 index 0000000..acb8451 --- /dev/null +++ b/heddle/RELEASE-NOTES-0.2.md @@ -0,0 +1,34 @@ +# machine-made Heddle 0.2 + +Heddle is a Swiss display grotesk built from four parts (rect, ring, +slant, vee) with a weight axis (100 to 900) and a width axis (70 to +140). Version 0.2 adds Central European languages and fixes the +heavy weights. + +## New + +- Latin Extended-A and Ș ș Ț ț: Polish, Czech, Slovak, Hungarian, + Romanian, Turkish, Croatian, Slovenian, Latvian, Lithuanian, + Estonian, Maltese and more. +- `¢ £ ¥ § ¶ ± × ÷ −`, `½ ¼ ¾`, and the `frac` feature for any + fraction (type 3/16 and turn on `frac`). +- Kerning for lowercase pairs and for figures before a period. +- Hinted static fonts (ttfautohint). + +## Better + +- Marks are lighter and smaller. ï and Ï no longer get wider at Black. +- Small rings and the hooks of a, f, j, s and t stay open at Black. +- ß, ð, @, ©, ® and % are redrawn. +- The variable font follows the generator more closely near width 85 + (four more masters). + +## Files + +- `MachineMadeHeddle[wdth,wght].ttf`: the variable font. +- 11 static TTF: Thin to Black at Normal width, Compressed Bold, + Extended Bold. +- WOFF2: the variable font, Regular and Bold. + +Free under the SIL Open Font License 1.1, Reserved Font Name +"machine-made". diff --git a/heddle/STATUS.md b/heddle/STATUS.md index 96280c5..97bbe9d 100644 --- a/heddle/STATUS.md +++ b/heddle/STATUS.md @@ -3,29 +3,76 @@ | Field | Value | | --- | --- | | Family | machine-made Heddle | -| Version | 0.1 (font version 0.100) | -| Pull request | machine-made/type#4 | -| Stage | Heddle 0.1 built: variable font (wght, wdth), 11 statics, WOFF2 | -| Gate now | Jac reviews PR #4; founder reviews the proofs by eye | -| Next | Release 0.1 (tag `heddle-v0.1.0`). Then 0.2 (see below) | -| Last local run | 2026-10-27: tests, build, byte check, interpolation, Fontspector, site build all green | -| Last green CI | none yet (first run is on the PR) | - -## Proven (local run, 2026-10-27) - -- 334 pytest tests pass. -- The build is byte-reproducible: two builds give the same files. -- `fonttools varLib.interpolatable`: no problems on the designspace, - on the 12 master UFOs and on the variable font. -- Fontspector 1.9.0, universal profile: 0 FAIL, 0 ERROR on the - variable font and the 11 static TTF. -- The 70 sketch characters match the sketch engine at the five corner - and default locations (`proofs/heddle/heddle-sketch-vs-font-*.png`, - soft IoU 0.96 to 0.97 at Regular and heavier; see "Sketch comparison"). -- Advances match the sketch engine exactly at all 12 masters (test runs +| Version | 0.2 (font version 0.200) | +| Branch | `heddle/v0.2` | +| Stage | Heddle 0.2 built: variable font (wght, wdth), 11 hinted statics, WOFF2 | +| Gate now | Jac reviews the PR; founder reviews the proofs by eye | +| Next | Release 0.2 (tag `heddle-v0.2.0`). Then the open items below | +| Last local run | 2026-10-28: tests, build, byte check, interpolation, Fontspector, drift, OCR check, site build all green | +| Last green CI | 0.1: PR #4. 0.2: on the PR | + +## Proven (local run, 2026-10-28) + +- 533 pytest tests pass. +- The build is byte-reproducible, hinting included: two builds give the + same files. +- `fonttools varLib.interpolatable`: no problems on the designspace, on + the 16 master UFOs and on the variable font. +- Fontspector 1.9.0, universal profile: 0 FAIL, 0 ERROR on the variable + font (4 WARN) and the 11 static TTF (47 WARN). Accepted WARNs below. +- Advances match the sketch engine exactly at all 16 masters (test runs the engine in node). -- Every static glyph equals the nonzero fill of its primitives (the - build checks each union and stops if one differs). +- Every static glyph equals the nonzero fill of its primitives, and no + static glyph keeps an overlap. No glyph needs a fallback (see + "Boolean step"). +- Drift (`proofs/heddle/drift.json`): largest shape drift 8.7 % (0.1: + 11.1 %), largest advance drift 30.6 units (0.1: 38.8). +- OCR check (`proofs/heddle/readability.json`, Tesseract 5.3.4, 24, 32 + and 48 px): CER Regular 0.17 % (0.1: 0 %), Bold 0.17 % (0.1: 0.34 %), + Black 0 % (0.1: 0 %). DejaVu Sans: 0.17 %, 0 %, 0 %. Gate (at most + 1 point above 0.1) passes. Heddle is a display face; this check stops + regressions only. +- Sketch comparison (`proofs/heddle/sketch-compare.json`): soft IoU + 0.96 to 0.97 at Regular and Black; between masters at wght 250, wdth + 85: 0.86 (0.1: 0.73). + +## Done in 0.2 + +- Drift at Black near wdth 85: masters at wdth 85 (16 masters). J at + wght 900, wdth 85 now has no drift (it is a master); the worst glyph + is now the hyphen at wght 550, wdth 85 (8.7 %: the width floor bends + between wght 400 and 700). +- Accents: lighter, smaller marks; ".narrow" marks on ı I l ȷ, so ï Ï + Ĩ ĺ do not widen their base. Capital marks stay below 950; win ascent + is 950 (0.1: 1051). +- Minimum counter rule: small rings and the hooks of a f j s t stay open + at Black. ß ð @ © ® % redrawn with it (© ® with lighter inner letters; + ð with its ascender starting inside the bowl; § new). +- Latin Extended-A (U+0100 to U+017F), Ș ș Ț ț, `¢ £ ¥ § ¶ ± × ÷ −`, + `½ ¼ ¾`, fraction slash, spacing marks, and `frac`, `numr`, `dnom`. + Tests check text in Polish, Czech, Slovak, Hungarian, Romanian, + Turkish, Croatian, Slovenian, Latvian, Lithuanian, Estonian, Maltese + and Esperanto. +- Kerning: lowercase pairs (v w y, r, f, k x, t, o b p against round + letters, a, period) and figures against punctuation. The no-touch + test now checks every member of every class, in every master. +- Boolean step without fallbacks (below). +- Hinting: ttfautohint on the statics. +- Name: register search for HEDDLE (see `BRIEF.md`). No mark for fonts + or software found; not renamed. + +## Boolean step + +0.1 needed two fallbacks: a jitter for the S spine, and the rounded but +not re-simplified path for 15 static glyphs. The second came from a bug +in the check, not in pathops: re-simplifying after rounding returns +quadratic curves, and the check could not read them. 0.2 does: union +(pathops), snap to integers and drop degenerate segments, split +self-touching contours into loops (pathops returned Œ at wght 800 with +its counter joined to the outer contour by a slit, wound the wrong way +for nonzero), wind each loop by its nesting depth, then check the +result against the nonzero fill and check that no overlap is left. No +static glyph needs a fallback; the build stops if one ever fails. ## Fontspector WARNs (accepted) @@ -33,67 +80,50 @@ | --- | --- | --- | | `contour_count` | VF | The variable font keeps one contour per primitive (on purpose). The statics pass. | | `mandatory_avar_table` | VF | Both axes are linear on purpose. | +| `suspicious_sidebearings` | VF | The space (its width floor `2.3 S` bends between masters) and ſ (its hook ring widens at heavy weights, minimum counter rule). Both on purpose. | +| `alt_caron` | statics | Fontspector cannot check ď ľ ť Ľ in decomposed statics. They use a vertical caron (`caroncomb.alt`); see `proofs/heddle/heddle-latin-ext.png`. | | `name/family_and_style_max_length` | 3 statics | PostScript names longer than 27 characters (`MachineMadeHeddle-CompressedBold`, `-ExtendedBold`, `-ExtraLight`). Only old PostScript printers care. | ## Interpolation drift -The variable font interpolates 12 masters. The engine's rules are not -all linear, so between masters the font can differ a little from what -the engine would draw. `tools/drift.py` measures it for every glyph at -21 locations between the masters (`proofs/heddle/drift.json`): - -- Median shape drift: 0. Most glyphs are the same. -- Largest: J at wght 900, wdth 85: 11 % of the ink area moves. Next: the - hyphen (9 % at wght 550), õ, z, the en dash, s, ä (5 to 7 %), all at - wdth 85, most at wght 800 to 900. -- Cause: the width floor `W >= 2.3 S` of the sketch. At Black it bends - between wdth 70 and 100. The font is up to 35 units wider there than - the engine. A wdth 85 master would cut this but add 4 masters. -- The space: up to 26 units wider between masters (same floor). -- Glyphs new in 0.1 use linear widths and do not drift in width. - -## Sketch comparison - -`tools/sketch_compare.py` renders the sketch engine and the font in -Chromium and writes `proofs/heddle/sketch-compare.json`. Soft IoU (1.0 -= same pixels): 0.96 to 0.97 at wght 400 and 900, 0.75 to 0.81 at -wght 100. Thin strokes are under 2 px at the proof size, so canvas and -font anti-aliasing differ a lot in relative terms; the overlay shows no -shape difference. Between masters, the pangram score drops (0.52 at -650, 120) because the spaces drift and the lines move apart. +The variable font interpolates 16 masters. `tools/drift.py` measures +every glyph at 25 locations between the masters (the 21 of 0.1 and 4 +new ones between wdth 70, 85 and 100): + +- Median shape drift: 0. +- Largest: the hyphen at wght 550, wdth 85 (8.7 %, 30.6 units of + advance). Next: J at wght 900, wdth 78 (6.8 %), ţ ț t at wght 550, + wdth 85 (about 6 %). +- Cause: the width floor `W >= 2.3 S` of the sketch bends inside the + cells, now also along the weight axis between 400 and 700. ## Deviations from the sketch -| What | Sketch | 0.1 | Why | +| What | Sketch | 0.2 | Why | | --- | --- | --- | --- | | Cuts | rectangles erased with `destination-out` | side cuts on one ring | No erasing of other primitives (the sketch bug that made stems vanish); fixed point count. | | Curves | 96-point polygon | one cubic per quarter | Compatible masters, clean outlines (within about 2 units of the curve). | | Hairline stroke | 1 px stroke to close seams | none | Overlaps fill correctly in the font. | -| Tabular figure width | wdth / 100 clamped to 0.8 to 1.25 | 0.8 at 70, 1 at 100, 1.25 at 140, linear between | The variable font interpolates between masters; this is what it gives. | +| Tabular figure width | wdth / 100 clamped to 0.8 to 1.25 | the sketch value at each master width, linear between | The variable font interpolates between masters. | | K arm | starts at x = S/2 | starts at x = max(S/2, t/2) | At heavy weights the sketch arm poked out left of the stem. | -| Closed counters | no inner contour when too small | inner contour shrinks to a point | Same look, same point structure in every master. | +| Closed counters | no inner contour when too small | minimum counter rule: inner radius at least 0.25 S, at most 38 units; the ring gets thinner | 0.2: small rings and hooks stay open at Black (a f j s t, B's upper bowl, %, ©, ®). At Black the strokes there are up to 55 % lighter than the stem. | +| Hooks of f, t, j | ring as wide as the sketch drew it | wider at heavy weights; j reaches left of its box | 0.2: so the hook counter stays open; no change at Regular. | | Cut levels near a counter's top or bottom | cut can end in the solid band | level kept within 97 % of the inner radius | A cut always ends in a clean terminal (e at Black). | | `&` | dropped | redrawn | The brief asked for a proper `&`. | -| Width between masters | `max(base wf, 2.3 S)` | interpolated from 12 masters | See "Interpolation drift". | - -## Open issues for 0.2 - -- Name: register search for HEDDLE before a public release (see - `BRIEF.md`). -- Width drift at Black near wdth 85: add masters at wdth 85, or make the - floor smooth. -- Accents at Black are wide and heavy; on narrow bases (ï, Ï) they - widen the glyph. Capital accents go above the typo ascender (950); - win metrics cover them. -- `ß`, `ð`, `@`, `©`, `®` and `%` are first drawings. At Black the small - rings of `%`, `©`, `®` and the hooks of a, f, j, s, t close (as in the - sketch). -- No Latin Extended-A beyond Œ œ Ÿ ı; no `¢ £ ¥ § ¶ ± × ÷` and - fractions. -- Kerning is a small class table (A V W Y T L P F against round letters - and punctuation). No kerning for lowercase pairs or figures. -- pathops fails on some unions (S spine) and on re-simplifying 15 - rounded static glyphs. The build detects both and falls back (jitter, - or the rounded union). A better boolean step would remove the - fallbacks. -- No hinting. +| Width between masters | `max(base wf, 2.3 S)` | interpolated from 16 masters | See "Interpolation drift". | + +## Open issues for 0.3 + +- Drift from the width floor along the weight axis (hyphen, t, ţ at + wght 550, wdth 85). A master at wght 550 or a smooth floor would cut + it. +- Marks are not encoded and there is no `mark`/`mkmk` feature: text + with combining marks (e + U+0301) does not compose. Precomposed + letters work. +- No proportional figures, so figures do not kern with each other + (Fontspector `tabular_kerning`). +- The ogonek and the vertical caron are first drawings; check them in + Polish and Czech headlines. +- § is new (an s with a ring as its spine); ¶ uses a half disc. Review + both by eye. +- Hinting is ttfautohint defaults; no hand-tuned control file. diff --git a/heddle/loom/build.py b/heddle/loom/build.py index f6576e8..a80486e 100644 --- a/heddle/loom/build.py +++ b/heddle/loom/build.py @@ -1,10 +1,10 @@ -"""Build the Heddle 0.1 sources: variable masters, designspace, static UFOs. +"""Build the Heddle sources: variable masters, designspace, static UFOs. Usage: python -m loom.build [--out build] Writes: - build/master/Heddle-wWWW-dDDD.ufo 12 masters (wght 100, 400, 700, 900 x wdth 70, 100, 140) + build/master/Heddle-wWWW-dDDD.ufo 16 masters (wght 100, 400, 700, 900 x wdth 70, 85, 100, 140) build/Heddle.designspace axes wght 100-900, wdth 70-140, 13 named instances build/static/MachineMadeHeddle-{Style}.ufo statics, overlaps removed @@ -44,7 +44,7 @@ FAMILY = "machine-made Heddle" PS_FAMILY = "MachineMadeHeddle" -VERSION = (0, 100) # 0.100: Heddle 0.1 +VERSION = (0, 200) # 0.200: Heddle 0.2 COPYRIGHT = "Copyright 2026 The machine-made Heddle Project Authors (https://github.com/machine-made/type)" LICENSE_DESCRIPTION = ( "This Font Software is licensed under the SIL Open Font License, " @@ -134,19 +134,130 @@ def ink_center(name: str, wght: float, wdth: float) -> float: return (min(xs) + max(xs)) / 2 if xs else adv / 2 +def ink_bounds(name: str, wght: float, wdth: float) -> tuple[float, float, float, float]: + contours, _ = drawn(name, wght, wdth) + xs = [x for c in contours for x, _, _ in c] + ys = [y for c in contours for _, y, _ in c] + return min(xs), min(ys), max(xs), max(ys) + + +def ink_spans(name: str, wght: float, wdth: float, y: float) -> list[tuple[float, float]]: + """Return the filled x intervals of a drawn glyph at height y (nonzero rule).""" + contours, _ = drawn(name, wght, wdth) + hits = [] + for c in contours: + poly = R._flatten(c, 16) + for (x0, y0), (x1, y1) in zip(poly, poly[1:] + poly[:1]): + if (y0 <= y < y1) or (y1 <= y < y0): + x = x0 + (y - y0) * (x1 - x0) / (y1 - y0) + hits.append((x, 1 if y1 > y0 else -1)) + hits.sort() + spans, wind, start = [], 0, None + for x, d in hits: + before = wind + wind += d + if before == 0 and wind != 0: + start = x + elif before != 0 and wind == 0: + spans.append((start, x)) + return spans + + +def top_anchor(base: str, wght: float, wdth: float) -> float: + """x of the centre of a top mark over a base.""" + if base == "dotlessj": + p = G.Params(wght, wdth) + return p.sb + G.GLYPHS[base].width(p) - p.S / 2 + return ink_center(base, wght, wdth) + + +def bottom_anchor(base: str, wght: float, wdth: float) -> float: + """x of the centre of a mark below a base (r: under the stem).""" + if base == "r": + p = G.Params(wght, wdth) + return p.sb + p.S / 2 + return ink_center(base, wght, wdth) + + +def mark_width(mark: str, wght: float, wdth: float) -> float: + """Ink width of a mark (curves flattened, so handles do not count).""" + contours, _ = drawn(mark, wght, wdth) + xs = [x for c in contours for x, _ in R._flatten(c, 16)] + return max(xs) - min(xs) + + +def mark_height(mark: str, wght: float, wdth: float) -> float: + contours, _ = drawn(mark, wght, wdth) + ys = [y for c in contours for _, y in R._flatten(c, 16)] + return max(ys) - min(ys) + + +def _advance(name: str, wght: float, wdth: float) -> float: + return drawn(name, wght, wdth)[1] + + def composite(name: str, wght: float, wdth: float): - """Return ([(glyph, dx, dy)], advance) of an accented letter.""" - _, base, acc = G.COMPOSITES[name] - _, adv = drawn(base, wght, wdth) - dx = ink_center(base, wght, wdth) - # A mark wider than a narrow base (i, I) widens the glyph, so that - # marks on neighbours do not touch. The base moves to the centre. - acc_contours, _ = drawn(acc, wght, wdth) - xs = [x for c in acc_contours for x, _, _ in c] + """Return ([(glyph, dx, dy)], advance) of a composite glyph. + + Marks are drawn around x = 0 with advance 0; drawn glyphs include + their left sidebearing. See G.Comp for the places. + """ + c = G.COMPOSITES[name] p = G.Params(wght, wdth) - need = (max(xs) - min(xs)) + p.sb - shift = max(0.0, (need - adv) / 2) - return [(base, shift, 0.0), (acc, dx + shift, 0.0)], adv + 2 * shift + if c.row: + if len(c.row) == 1 and c.row[0] in G.accent_names(): + x0, _, x1, _ = ink_bounds(c.row[0], wght, wdth) + return [(c.row[0], p.sb - x0, 0.0)], (x1 - x0) + 2 * p.sb + comps, x = [], 0.0 + for gname in c.row: + comps.append((gname, x, 0.0)) + x += _advance(gname, wght, wdth) + return comps, x + base = c.base + adv = _advance(base, wght, wdth) + shift = 0.0 + # A top mark wider than a narrow base widens the glyph, so that marks + # on neighbours do not touch; the base moves to the centre. The + # .narrow marks (0.2) fit: ï Ï Ĩ ĺ keep the advance of their base. + for mark, place in c.marks: + if place in ("top", "top-asc"): + need = mark_width(mark, wght, wdth) + 0.5 * p.sb + shift = max(shift, (need - adv) / 2) + shift = shift if shift > 0.5 else 0.0 + comps = [(base, shift, 0.0)] + for mark, place in c.marks: + if place == "top": + comps.append((mark, top_anchor(base, wght, wdth) + shift, 0.0)) + elif place == "top-asc": + comps.append((mark, top_anchor(base, wght, wdth) + shift, float(G.ASC - G.CAP))) + elif place == "bottom": + comps.append((mark, bottom_anchor(base, wght, wdth) + shift, 0.0)) + elif place == "ogonek": + a, b = ink_spans(base, wght, wdth, 4.0)[-1] + wo = mark_width(mark, wght, wdth) + right = b if b - a >= wo else (a + b) / 2 + wo / 2 + comps.append((mark, right + shift, 0.0)) + elif place == "right": + # The vertical caron: its top at the ascender (lowercase) or + # the cap height, right of the ink it stands next to. + top = max(ink_bounds(base, wght, wdth)[3], G.CAP if base[0].isupper() else G.ASC) + y0 = top - mark_height(mark, wght, wdth) + btop = ink_bounds(base, wght, wdth)[3] + xr = max([sp[-1][1] for y in range(int(y0), int(top), 10) + if (sp := ink_spans(base, wght, wdth, y + 0.5))] + + [ink_spans(base, wght, wdth, btop - 20)[-1][1]]) + x = xr + 0.45 * p.sb + 8 + comps.append((mark, x + shift, top)) + adv = max(adv, x + mark_width(mark, wght, wdth) + 0.6 * p.sb) + elif place == "dot": + top = ink_bounds(base, wght, wdth)[3] + x = ink_spans(base, wght, wdth, top - 20)[0][1] + 0.5 * p.sb + 12 + dy = (G.CAP - G.XH) / 2 if base == "L" else 0.0 + comps.append((mark, x - p.sb + shift, dy)) + adv = max(adv, x + G.dot_size(p) + p.sb) + else: + raise ValueError(place) + return comps, adv + 2 * shift def structure(contours) -> tuple: @@ -232,6 +343,30 @@ def set_info(font: ufoLib2.Font, wght: float, wdth: float, *, static: bool) -> N info.openTypeHheaLineGap = 0 font.lib["com.github.googlei18n.ufo2ft.featureWriters"] = FEATURE_WRITERS font.lib["public.postscriptNames"] = {} + font.features.text = features() + + +def features() -> str: + """OpenType feature code: frac, numr and dnom (0.2). ufo2ft adds kern.""" + figs = " ".join(G.FIGURES) + numr = " ".join(f"{f}.numr" for f in G.FIGURES) + dnom = " ".join(f"{f}.dnom" for f in G.FIGURES) + return ( + "languagesystem DFLT dflt;\n" + "languagesystem latn dflt;\n\n" + f"@figures = [{figs}];\n" + f"@numr = [{numr}];\n" + f"@dnom = [{dnom}];\n\n" + "# Fractions: figures before the slash become numerators, figures\n" + "# after it denominators; the slash becomes the fraction slash.\n" + "feature frac {\n" + " lookup FRAC_SLASH { sub slash by fraction; } FRAC_SLASH;\n" + " lookup FRAC_NUMR { sub @figures by @numr; } FRAC_NUMR;\n" + " lookup FRAC_DNOM { sub [fraction @dnom] @numr' by @dnom; } FRAC_DNOM;\n" + "} frac;\n\n" + "feature numr { sub @figures by @numr; } numr;\n" + "feature dnom { sub @figures by @dnom; } dnom;\n" + ) def set_unicodes(font: ufoLib2.Font) -> None: @@ -377,62 +512,226 @@ def union(contours) -> pathops.Path: return path -def groups(contours) -> list[list]: - """Split loom contours into primitives: a ring outer with its counter.""" - out = [] - for c in contours: - if signed_area(c) < 0 and out: - out[-1].append(c) +def path_segments(path) -> list[list]: + """Return the contours of a pathops path as lists of (op, points).""" + from fontTools.pens.recordingPen import RecordingPen + + rec = RecordingPen() + path.draw(rec) + out, cur = [], [] + for op, args in rec.value: + if op == "moveTo": + cur = [("moveTo", [tuple(args[0])])] + elif op in ("closePath", "endPath"): + if cur: + out.append(cur) + cur = [] else: - out.append([c]) + cur.append((op, [tuple(a) for a in args])) return out -def robust_union(contours, reference=None) -> pathops.Path: - """Return the union of loom contours, checked against a nonzero fill. +def snap(path) -> list[list]: + """Round a union result to integer points, dropping degenerate segments. - pathops can drop a piece where two curves nearly coincide (the two - rings of S at the spine). When the result differs from the nonzero - fill of the raw contours, move each primitive by a tiny step (0.001 - unit times its index; rounding removes it) and try again. Stop when - that fails too. + Where pathops splits curves near an intersection, two points can + round to the same place. A line of zero length or a curve whose + points are all one point is dropped; a contour with less than three + distinct points (on- or off-curve) is dropped. """ - ref = R.nonzero_mask(contours) if reference is None else reference - path = union(contours) - if R.mismatch(ref, R.path_mask(path)) == 0: - return path - for eps in (1e-3, 1e-2): - moved = [[(x + eps * k, y + 0.7 * eps * k, on) for x, y, on in c] - for k, grp in enumerate(groups(contours)) for c in grp] - path = union(moved) - if R.mismatch(ref, R.path_mask(path)) == 0: - return path - raise SystemExit("Overlap removal failed; the union differs from the nonzero fill.") + out = [] + for contour in path_segments(path): + start = (rnd(contour[0][1][0][0]), rnd(contour[0][1][0][1])) + segs, last = [], start + for op, pts in contour[1:]: + q = [(rnd(x), rnd(y)) for x, y in pts] + if all(pt == last for pt in q): + continue + if op == "lineTo" or (op == "curveTo" and q[0] in (last, q[-1]) and q[1] in (last, q[-1])): + segs.append(("lineTo", [q[-1]])) + else: + segs.append((op, q)) + last = q[-1] + if segs and segs[-1][1][-1] == start and segs[-1][0] == "lineTo": + segs.pop() + if len({start} | {pt for s in segs for pt in s[1]}) >= 3: + out.append([("moveTo", [start])] + segs) + return out + + +def split_touching(contours) -> list[list]: + """Split contours that touch themselves into simple loops. + + pathops sometimes returns one contour that runs out to a counter and + back along a slit of zero width (where a ring is tangent to a bar, + as in Œ). Such a contour can wind the counter the wrong way for the + nonzero rule. Each loop between two visits of one point becomes its + own contour; loops of no area (the slits) are dropped. orient() + then sets the directions. + """ + out = [] + todo = [c for c in contours] + while todo: + c = todo.pop() + start = c[0][1][0] + ends = [start] + [seg[1][-1] for seg in c[1:]] + seen, split = {}, None + for i, pt in enumerate(ends[:-1] if ends[-1] == start else ends): + if pt in seen: + split = (seen[pt], i) + break + seen[pt] = i + if split is None: + out.append(c) + continue + i, j = split + segs = c[1:] + loop = [("moveTo", [ends[i]])] + segs[i:j] + rest_segs = segs[:i] + segs[j:] + todo.append(loop) + if rest_segs: + todo.append([("moveTo", [start])] + rest_segs) + return [c for c in out if abs(_flat_area([c])) >= 1.0] + + +def _poly(contour) -> list: + return R._flatten(_seg_contours([contour])[0], 16) + + +def _inside(pt, poly) -> bool: + x, y = pt + hit = False + for (x0, y0), (x1, y1) in zip(poly, poly[1:] + poly[:1]): + if (y0 > y) != (y1 > y) and x < x0 + (y - y0) * (x1 - x0) / (y1 - y0): + hit = not hit + return hit + + +def orient(contours) -> list[list]: + """Wind each loop by its nesting depth: outer loops counter-clockwise, + counters clockwise (so nonzero and even-odd fill agree).""" + polys = [_poly(c) for c in contours] + out = [] + for k, c in enumerate(contours): + depth = 0 + for m, other in enumerate(polys): + if m == k: + continue + votes = sum(_inside(pt, other) for pt in polys[k][::4]) + if 2 * votes > len(polys[k][::4]): + depth += 1 + want_ccw = depth % 2 == 0 + if (_flat_area([c]) > 0) != want_ccw: + c = _reverse_segments(c) + out.append(c) + return out + +def _reverse_segments(contour) -> list: + start = contour[0][1][0] + pts = [start] + [seg[1][-1] for seg in contour[1:]] + out = [("moveTo", [pts[-1]])] + for k in range(len(contour) - 1, 0, -1): + op, q = contour[k] + prev = pts[k - 1] + if op == "lineTo": + out.append(("lineTo", [prev])) + else: + out.append((op, list(reversed(q[:-1])) + [prev])) + if out[-1][1][-1] != pts[-1] or len(out) == 1: + out.append(("lineTo", [pts[-1]])) + # Drop a closing line back to the start (closePath draws it). + if out[-1][0] == "lineTo" and out[-1][1][0] == out[0][1][0]: + out.pop() + return out -class _RoundPen: - """A pen that rounds every point before it reaches the target pen.""" - def __init__(self, pen): - self.pen = pen +def _flat_area(contours) -> float: + """Signed area of (op, points) contours, curves flattened finely.""" + total = 0.0 + for contour in contours: + pts = [contour[0][1][0]] + for op, q in contour[1:]: + p0 = pts[-1] + if op == "lineTo": + pts.append(q[0]) + elif op == "curveTo": + for k in range(1, 33): + t = k / 32 + m = 1 - t + pts.append((m ** 3 * p0[0] + 3 * m * m * t * q[0][0] + 3 * m * t * t * q[1][0] + t ** 3 * q[2][0], + m ** 3 * p0[1] + 3 * m * m * t * q[0][1] + 3 * m * t * t * q[1][1] + t ** 3 * q[2][1])) + elif op == "qCurveTo": + # One or more quadratics with implied on-curve points. + offs, end = q[:-1], q[-1] + ons = [p0] + [((a[0] + b[0]) / 2, (a[1] + b[1]) / 2) for a, b in zip(offs, offs[1:])] + [end] + for i, c in enumerate(offs): + a, b = ons[i], ons[i + 1] + for k in range(1, 33): + t = k / 32 + m = 1 - t + pts.append((m * m * a[0] + 2 * m * t * c[0] + t * t * b[0], + m * m * a[1] + 2 * m * t * c[1] + t * t * b[1])) + for (x0, y0), (x1, y1) in zip(pts, pts[1:] + pts[:1]): + total += x0 * y1 - x1 * y0 + return total / 2 + + +def _draw_segments(contours, pen) -> None: + for contour in contours: + for op, pts in contour: + getattr(pen, op)(*pts) + pen.closePath() - def moveTo(self, p): - self.pen.moveTo((rnd(p[0]), rnd(p[1]))) - def lineTo(self, p): - self.pen.lineTo((rnd(p[0]), rnd(p[1]))) +def _seg_contours(contours) -> list: + """(op, points) contours as loom contours [(x, y, on)] (cubics only).""" + out = [] + for contour in contours: + c = [(*contour[0][1][0], True)] + for op, q in contour[1:]: + if op == "lineTo": + c.append((*q[0], True)) + else: + c += [(*q[0], False), (*q[1], False), (*q[2], True)] + if c[-1][:2] == c[0][:2] and len(c) > 1 and c[-1][2]: + c = c[:-1] + out.append(c) + return out - def curveTo(self, *pts): - self.pen.curveTo(*[(rnd(x), rnd(y)) for x, y in pts]) - def qCurveTo(self, *pts): - self.pen.qCurveTo(*[(rnd(x), rnd(y)) for x, y in pts]) +class OverlapError(SystemExit): + pass - def closePath(self): - self.pen.closePath() - def endPath(self): - self.pen.endPath() +def clean_union(name: str, contours) -> list: + """Return the overlap-free outline of loom contours, with integer points. + + 0.2 boolean step, no fallbacks: + + 1. union with skia-pathops (nonzero fill, one path); + 2. snap: round to integers and drop the degenerate segments that + rounding makes (see snap); + 3. split contours that touch themselves into simple loops and wind + each loop by its nesting depth (split_touching, orient); + 4. check that the result equals the nonzero fill of the primitives + (raster, 4 units per pixel) and that it has no overlap left: its + signed area equals the signed area of its own union, within + 2 square units. + + Stops the build when a check fails. + """ + ref = R.nonzero_mask(contours) + path = union(contours) + snapped = orient(split_touching(snap(path))) + out = _seg_contours(snapped) + if R.mismatch(ref, R.path_mask(_to_path(out))) != 0: + raise OverlapError(f"Glyph {name!r}: the union differs from the nonzero fill.") + again = _to_path(out) + again.simplify(fix_winding=True, keep_starting_points=False, clockwise=False) + if abs(_flat_area(snapped) - _flat_area(path_segments(again))) > 2.0: + raise OverlapError(f"Glyph {name!r}: overlaps are left after snapping.") + return snapped def build_static(wght: float, wdth: float) -> ufoLib2.Font: @@ -446,20 +745,7 @@ def build_static(wght: float, wdth: float) -> ufoLib2.Font: contours, adv = interpolated(name, wght, wdth) g.width = rnd(adv) if contours: - ref = R.nonzero_mask(contours) - path = robust_union(contours, ref) - # Rounding can make a new overlap: simplify again after it, - # unless pathops gets that wrong (then keep the rounded path). - rounded = pathops.Path() - path.draw(_RoundPen(rounded.getPen())) - tmp = pathops.Path() - rounded.draw(tmp.getPen()) - tmp.simplify(fix_winding=True, keep_starting_points=False, clockwise=False) - if R.mismatch(ref, R.path_mask(tmp)) != 0: - tmp = rounded - if R.mismatch(ref, R.path_mask(tmp)) != 0: - raise SystemExit(f"Glyph {name!r}: rounding after overlap removal changed the shape.") - tmp.draw(_RoundPen(g.getPen())) + _draw_segments(clean_union(name, contours), g.getPen()) set_unicodes(font) font.groups.update(K.groups()) # Kerning: the same interpolation as the variable font. @@ -519,7 +805,7 @@ def build_designspace(masters: dict) -> DesignSpaceDocument: def main(argv: list[str] | None = None) -> int: - ap = argparse.ArgumentParser(description="Build the Heddle 0.1 UFO sources.") + ap = argparse.ArgumentParser(description="Build the Heddle UFO sources.") ap.add_argument("--out", default="build") ap.add_argument("--no-static", action="store_true", help="Do not write the static UFOs.") args = ap.parse_args(argv) diff --git a/heddle/loom/geom.py b/heddle/loom/geom.py index 696a961..da06417 100644 --- a/heddle/loom/geom.py +++ b/heddle/loom/geom.py @@ -48,10 +48,22 @@ # Smallest inner radius of a ring. A ring whose counter closes keeps a # tiny counter, so that the point structure does not change. MIN_INNER = 0.5 +# Minimum counter rule (0.2): a ring's counter keeps at least this inner +# radius, as a share of the stem, clamped per weight (see counter_min). +# The ring gets thinner to keep it. A ring never gets thinner than +# THIN_LIMIT of its stroke; then its counter may still close. +CMIN_K = 0.25 +CMIN_MAX = 38.0 +THIN_LIMIT = 0.45 # Rings whose counter closed (for diagnostics; see tools/drift.py). CLOSED: list = [] +def counter_min(S: float) -> float: + """Smallest inner radius of a ring at stem S: 0.25 S, at most 38 units.""" + return min(CMIN_MAX, CMIN_K * S) + + def _sp(v: float) -> float: """Signed power: sign(v) * |v| ** P.""" return math.copysign(abs(v) ** P, v) @@ -295,12 +307,25 @@ def polygon(pts): return c if signed_area(c) >= 0 else reverse(c) -def ring_contours(x0, y0, x1, y1, cuts=(), ts=0.0, th=0.0, margin=AXIS_MARGIN) -> list: - """Return the contours of a superellipse ring with cuts.""" +def open_strokes(rx: float, ry: float, ts: float, th: float, cmin: float) -> tuple[float, float]: + """Return the stroke widths (ts, th) after the minimum counter rule.""" + if cmin <= 0.0: + return ts, th + ts = max(min(ts, rx - cmin), THIN_LIMIT * ts) + th = max(min(th, ry - cmin), THIN_LIMIT * th) + return ts, th + + +def ring_contours(x0, y0, x1, y1, cuts=(), ts=0.0, th=0.0, margin=AXIS_MARGIN, cmin=0.0) -> list: + """Return the contours of a superellipse ring with cuts. + + cmin: the minimum counter radius (see counter_min); 0 turns it off. + """ x0, x1 = min(x0, x1), max(x0, x1) y0, y1 = min(y0, y1), max(y0, y1) cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0 rx, ry = (x1 - x0) / 2.0, (y1 - y0) / 2.0 + ts, th = open_strokes(rx, ry, ts, th, cmin) irx, iry = rx - ts, ry - th if irx < MIN_INNER or iry < MIN_INNER: # The counter is closed. Keep it as a tiny point at the centre, so @@ -347,9 +372,11 @@ def vee_points(x0, x1, yT, yB, t): class Canvas: """Collects contours. Mirrors the drawing tools of the sketch engine.""" - def __init__(self, S: float, H: float): + def __init__(self, S: float, H: float, cmin: float | None = None): self.S = S self.H = H + # Minimum counter radius for every ring (0 turns the rule off). + self.cmin = counter_min(S) if cmin is None else cmin self.contours: list = [] self._m = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0) @@ -391,11 +418,15 @@ def rect(self, x0, y0, x1, y1): y0, y1 = min(y0, y1), max(y0, y1) self.add(polygon([(x0, y0), (x1, y0), (x1, y1), (x0, y1)])) - def ring(self, x0, y0, x1, y1, cuts=(), ts=None, th=None, margin=AXIS_MARGIN): - """A ring. `margin`: a smaller value keeps axis points close to a cut.""" + def ring(self, x0, y0, x1, y1, cuts=(), ts=None, th=None, margin=AXIS_MARGIN, cmin=None): + """A ring. `margin`: a smaller value keeps axis points close to a cut. + + `cmin`: the minimum counter radius (default: the canvas value). + """ ts = self.S if ts is None else ts th = self.H if th is None else th - for c in ring_contours(x0, y0, x1, y1, cuts, ts, th, margin): + cmin = self.cmin if cmin is None else cmin + for c in ring_contours(x0, y0, x1, y1, cuts, ts, th, margin, cmin): self.add(c) def disc(self, x0, y0, x1, y1): diff --git a/heddle/loom/glyphs.py b/heddle/loom/glyphs.py index 3b06a7b..634fdba 100644 --- a/heddle/loom/glyphs.py +++ b/heddle/loom/glyphs.py @@ -28,13 +28,13 @@ from dataclasses import dataclass, field from typing import Callable -from loom.geom import INF +from loom.geom import INF, open_strokes UPM = 1000 XH, CAP, ASC, DESC, OS = 520, 700, 730, -210, 10 WGHT = (100, 400, 700, 900) -WDTH = (70, 100, 140) +WDTH = (70, 85, 100, 140) FW = 560 # tabular figure width at wdth 100 @@ -67,15 +67,23 @@ def sb(self) -> float: return 22 + 0.22 * self.S +# Width factor of the tabular figures at the master widths: the sketch +# value wdth/100, clamped to 0.8..1.25. +_TAB = ((0.70, 0.80), (0.85, 0.85), (1.00, 1.00), (1.40, 1.25)) + + def tab_factor(wf: float) -> float: - """Width factor of the tabular figures: 0.8 at wdth 70, 1 at 100, 1.25 at 140. + """Width factor of the tabular figures: 0.8 at wdth 70, 0.85 at 85, + 1 at 100, 1.25 at 140. - Linear between the master widths, so the variable font gives the - same value at every width (the sketch clamps wdth/100 to 0.8..1.25). + The sketch value at every master width, linear between them, so the + variable font gives the same value at every width (the sketch clamps + wdth/100 to 0.8..1.25). """ - if wf <= 1.0: - return 1.0 - (1.0 - wf) * (0.2 / 0.3) - return 1.0 + (wf - 1.0) * (0.25 / 0.4) + for (a, fa), (b, fb) in zip(_TAB, _TAB[1:]): + if wf <= b or b == _TAB[-1][0]: + return fa + (wf - a) * (fb - fa) / (b - a) + return _TAB[-1][1] @dataclass @@ -136,6 +144,20 @@ def i_gap(S: float) -> float: # --------------------------------------------------- shared constructions +def hook_span(g, wr): + """Return (width, terminal stroke) of a hook ring on a stem. + + A hook is half a ring whose one side runs into a stem as wide as + the ring's stroke (f, t, j). Its visible counter lies between the + stem and the terminal. 0.2 rule: that counter is at least two + minimum counter radii wide. The ring gets wider (up to 1.8 stems + plus the counter), then its terminal gets thinner. At light and + regular weights nothing changes. + """ + wr = max(wr, 1.8 * g.S + 2 * g.cmin) + return wr, min(g.S, wr - g.S - 2 * g.cmin) + + def arch(g, x0, x1, top, stem_to): """n shoulder: the upper half of a ring, then the right stem.""" ry = min((x1 - x0) * 0.52, top * 0.62) @@ -149,7 +171,10 @@ def bowl_d(g, x0, y0, x1, y1, s=None, h=None): """Square on the left, round on the right.""" s = g.S if s is None else s h = g.H if h is None else h - g.ring(x0, y0, x1, y1, ts=s, th=h) + # The minimum counter rule for the whole bowl: the bars get the same + # strokes as the ring, so that the bars do not close the counter. + s, h = open_strokes((x1 - x0) / 2, (y1 - y0) / 2, s, h, g.cmin) + g.ring(x0, y0, x1, y1, ts=s, th=h, cmin=0.0) g.rect(x0, y0, x0 + (x1 - x0) / 2, y0 + h) g.rect(x0, y1 - h, x0 + (x1 - x0) / 2, y1) g.rect(x0, y0, x0 + s, y1) @@ -336,13 +361,21 @@ def _dotlessi(g, W): _i_stem(g, W) -@sketch("j", 0x6A, kind="stem", stem_width=lambda p: p.S + 120) -def _j(g, W): +def _j_body(g, W): x = W - g.S y0 = DESC - OS cy = DESC + 80 - g.ring(0, y0, W, DESC + 170, [("l", cy, INF), ("r", cy, INF)]) + # At heavy weights the hook reaches left of the glyph box, so that + # its counter stays open (0.2; the sketch closed it). + wr, ts = hook_span(g, W) + g.ring(W - wr, y0, W, DESC + 170, [("l", cy, INF), ("r", cy, INF)], ts=ts) g.rect(x, cy - 1, W, XH) + + +@sketch("j", 0x6A, kind="stem", stem_width=lambda p: p.S + 120) +def _j(g, W): + _j_body(g, W) + x = W - g.S gap = i_gap(g.S) g.rect(x, XH + gap, W, XH + gap + g.S * 1.04) @@ -358,7 +391,8 @@ def _f(g, W): r = min(150, (W - x) / 2 + 10) g.rect(x, 0, x + g.S, ASC - r + 1) c = ASC + OS - r - g.ring(x, ASC + OS - 2 * r, x + 2 * r + 40, ASC + OS, [("l", -INF, c), ("r", -INF, c)]) + wr, ts = hook_span(g, 2 * r + 40) + g.ring(x, ASC + OS - 2 * r, x + wr, ASC + OS, [("l", -INF, c), ("r", -INF, c)], ts=ts) g.rect(0, XH - g.H, W, XH) @@ -368,7 +402,8 @@ def _t(g, W): r = min(130, (W - x) / 2) g.rect(x, r - OS - 1, x + g.S, XH + 150) c = r - OS - g.ring(x, -OS, x + 2 * r + 30, 2 * r - OS, [("l", c, INF), ("r", c, INF)]) + wr, ts = hook_span(g, 2 * r + 30) + g.ring(x, -OS, x + wr, 2 * r - OS, [("l", c, INF), ("r", c, INF)], ts=ts) g.rect(0, XH - g.H, W, XH) @@ -1107,20 +1142,25 @@ def _circle_frame(g, W): @glyph("copyright", 0xA9, 760, floor=5.0) def _copyright(g, W): _circle_frame(g, W) - s, h = g.S * 0.8, g.H * 0.8 - g.ring(W * 0.28, CAP * 0.24, W * 0.72, CAP * 0.76, [("r", CAP * 0.40, CAP * 0.60)], ts=s, th=h) + # The inner c is as light as the frame, so that its counter stays + # open at Black. + s, h = g.S * 0.66, g.H * 0.66 + g.ring(W * 0.27, CAP * 0.22, W * 0.73, CAP * 0.78, [("r", CAP * 0.40, CAP * 0.60)], ts=s, th=h) @glyph("registered", 0xAE, 760, floor=5.0) def _registered(g, W): _circle_frame(g, W) - s, h = g.S * 0.8, g.H * 0.8 - x0, x1 = W * 0.33, W * 0.68 - y0, y1 = CAP * 0.22, CAP * 0.78 + # The inner R: as light as the frame; its bowl follows the minimum + # counter rule (bowl_d), so that it stays open at Black. + s, h = g.S * 0.62, g.H * 0.62 + x0, x1 = W * 0.33, W * 0.69 + y0, y1 = CAP * 0.21, CAP * 0.79 + ym = CAP * 0.45 g.rect(x0, y0, x0 + s, y1) - bowl_d(g, x0, CAP * 0.47, x1, y1, s, h) - t = g.dt((x1 - x0) * 0.5, CAP * 0.25) * 0.8 - g.par((x0 + x1) / 2, CAP * 0.47 + h * 0.5, x1 - t / 2, y0, t) + bowl_d(g, x0, ym, x1, y1, s, h) + t = g.dt((x1 - x0) * 0.5, ym - y0) * 0.62 + g.par((x0 + x1) / 2, ym + h * 0.5, x1 - t / 2, y0, t) def _degree_w(p): @@ -1239,7 +1279,7 @@ def _oslash(g, W): g.par(W * 0.08 + t / 2, -OS - 30, W * 0.92 - t / 2, XH + OS + 30, t) -@glyph("Eth", 0xD0, 720) +@glyph("Eth", (0xD0, 0x110), 720) def _Eth(g, W): e = W * 0.10 g.shift(e, 0, lambda: bowl_d(g, 0, 0, W - e, CAP)) @@ -1263,11 +1303,291 @@ def _thorn(g, W): def _eth(g, W): top = XH * 1.04 g.ring(0, -OS, W, top + OS) - # The ascender leans back from the right side of the bowl. - t = g.dt(W * 0.30, ASC - top * 0.62) * 0.85 - g.par(W - t / 2, top * 0.62, W * 0.62, ASC, t) - yc = ASC * 0.86 - g.rect(W * 0.40, yc - g.H * 0.36, W * 0.98, yc + g.H * 0.36) + # The ascender leans back from the right side of the bowl. Its foot + # starts inside the bowl's outer edge at that height, so that no + # corner pokes out (0.2). + yb = top * 0.62 + t = g.dt(W * 0.30, ASC - yb) * 0.85 + ry = (top + 2 * OS) / 2 + v = abs((yb - (top / 2)) / ry) + xr = W / 2 + (W / 2) * (1 - v ** 2.6) ** (1 / 2.6) + g.par(xr - t / 2 - 2, yb, W * 0.60, ASC, t) + yc = ASC * 0.84 + g.rect(W * 0.38, yc - g.H * 0.38, W * 0.96, yc + g.H * 0.38) + + +# ------------------------------------------- Latin Extended-A (drawn, 0.2) + +@glyph("dotlessj", 0x237, kind="stem", stem_width=lambda p: p.S + 120) +def _dotlessj(g, W): + _j_body(g, W) + + +def _bar_y_asc(): + """Height of the bar of đ ħ: between the x-height and the ascender.""" + return (XH + OS + ASC) / 2 + 15 + + +@glyph("dcroat", 0x111, 600) +def _dcroat(g, W): + e = 0.10 * W + wd = W - e + g.flipX(wd, lambda: _bee(g, wd)) + hb = 0.72 * g.H + yc = _bar_y_asc() + g.rect(wd - g.S - 0.24 * wd, yc - hb / 2, W, yc + hb / 2) + + +@glyph("hbar", 0x127, 600) +def _hbar(g, W): + e = 0.10 * W + g.shift(e, 0, lambda: _h(g, W - e)) + hb = 0.72 * g.H + yc = _bar_y_asc() + g.rect(0, yc - hb / 2, e + g.S + 0.24 * (W - e), yc + hb / 2) + + +@glyph("Hbar", 0x126, 720) +def _Hbar(g, W): + e = 0.06 * W + g.shift(e, 0, lambda: _H(g, W - 2 * e)) + hb = 0.72 * g.H + yc = CAP * 0.47 + g.H + (CAP - CAP * 0.47 - g.H) * 0.5 + g.rect(0, yc - hb / 2, W, yc + hb / 2) + + +@glyph("Tbar", 0x166, 580) +def _Tbar(g, W): + _T(g, W) + hb = 0.72 * g.H + yc = CAP * 0.45 + a = 0.27 * W + 0.5 * g.S + g.rect(W / 2 - a, yc - hb / 2, W / 2 + a, yc + hb / 2) + + +@glyph("tbar", 0x167, 330) +def _tbar(g, W): + _t(g, W) + hb = 0.72 * g.H + yc = XH * 0.50 + x = W * 0.18 + g.rect(x - 0.6 * x, yc - hb / 2, x + g.S + 0.6 * x + 0.1 * W, yc + hb / 2) + + +def _slash_bar(g, cx, cy, length): + """The bar of Ł ł: a rectangle turned 60 degrees from vertical.""" + import math + bar_poly(g, cx, cy, length, 0.78 * g.H, math.radians(62)) + + +def _lslash_e(S): + """How far the bar of ł reaches out of the stem on each side.""" + return 40 + 0.45 * S + + +@glyph("Lslash", 0x141, 560) +def _Lslash(g, W): + e = 0.12 * W + g.shift(e, 0, lambda: _L(g, W - e)) + _slash_bar(g, e + g.S / 2, CAP * 0.42, g.S + 2.4 * e) + + +@glyph("lslash", 0x142, kind="stem", stem_width=lambda p: p.S + 2 * _lslash_e(p.S)) +def _lslash(g, W): + e = _lslash_e(g.S) + g.rect(e, 0, e + g.S, ASC) + _slash_bar(g, e + g.S / 2, ASC * 0.50, g.S + 2.2 * e) + + +@glyph("kgreenlandic", 0x138, 500) +def _kgreenlandic(g, W): + g.rect(0, 0, g.S, XH) + t = g.dt(W * 0.7, XH) + g.par(g.S * 0.6, XH * 0.30, W - t / 2, XH, t) + g.par(W * 0.34, XH * 0.52, W - t / 2, 0, t) + + +def _hook_below(g, x1, y_top, y_bot, wr0): + """A j hook: the lower half of a ring ending at x1 (its right side under + a stem that ends at x1). Returns the ring's centre level.""" + wr, ts = hook_span(g, wr0) + cy = (y_top + y_bot) / 2 + g.ring(x1 - wr, y_bot, x1, y_top, [("l", cy, INF), ("r", cy, INF)], ts=ts) + return cy + + +@glyph("Eng", 0x14A, 650) +def _Eng(g, W): + g.rect(0, 0, g.S, CAP) + t = g.dt(W, CAP) + g.poly([(0, CAP), (t, CAP), (W, 0), (W - t, 0)]) + cy = _hook_below(g, W, -10 + 170, DESC - OS, 0.62 * W) + g.rect(W - g.S, cy - 1, W, CAP) + + +@glyph("eng", 0x14B, 540) +def _eng(g, W): + g.rect(0, 0, g.S, XH) + arch(g, 0, W, XH, XH * 0.4) + cy = _hook_below(g, W, DESC + 170, DESC - OS, 0.55 * W) + g.rect(W - g.S, cy - 1, W, XH * 0.5) + + +@glyph("longs", 0x17F, 280) +def _longs(g, W): + r = min(150, W / 2 + 10) + g.rect(0, 0, g.S, ASC - r + 1) + c = ASC + OS - r + wr, ts = hook_span(g, 2 * r + 40) + g.ring(0, ASC + OS - 2 * r, wr, ASC + OS, [("l", -INF, c), ("r", -INF, c)], ts=ts) + + +# ---------------------------------------------------- symbols (0.2) + +@glyph("cent", 0xA2, FW, kind="tab") +def _cent(g, W): + y1 = XH + 90 + g.ring(W * 0.08, -OS, W * 0.92, y1, [("r", y1 * 0.30, y1 * 0.70)]) + b = g.S * 0.7 + xc = W * 0.5 + g.rect(xc - b / 2, -100, xc + b / 2, y1 + 100) + + +@glyph("sterling", 0xA3, FW, kind="tab") +def _sterling(g, W): + S, H = g.S, g.H + x = W * 0.20 + r = min(0.30 * W + 0.3 * S, (W - x) / 2) + top = CAP + OS + c = top - r + # The stem rises into an arch that ends in a terminal on the right. + g.rect(x, H - 1, x + S, c + 1) + wr = max(2 * r, 1.8 * S + 2 * g.cmin) + g.ring(x, top - 2 * r, x + wr, top, [("l", -INF, c), ("r", -INF, c + 0.15 * r)], + ts=min(S, wr - S - 2 * g.cmin)) + yb = CAP * 0.42 + g.rect(0, yb - 0.8 * H, x + S + 0.32 * W, yb) + g.rect(0, 0, W, H) + + +@glyph("yen", 0xA5, FW, kind="tab") +def _yen(g, W): + yj = CAP * 0.42 + g.vee(0, W, CAP, yj, g.dt(W / 2, CAP - yj)) + g.rect(W / 2 - g.S / 2, 0, W / 2 + g.S / 2, yj + g.H * 0.2) + bh = 0.62 * g.H + gap = 26 + 0.18 * g.H + a = 0.36 * W + y1 = yj - 6 + for y in (y1, y1 - bh - gap): + g.rect(W / 2 - a, y - bh, W / 2 + a, y) + + +@glyph("section", 0xA7, 500, floor=3.0) +def _section(g, W): + # An s whose spine is a ring: the top of an s (terminal upper right), + # a middle ring, the bottom of an s (terminal lower left). The hooks + # share their inner strokes with the middle ring, as S shares its + # spine. + top, bot = CAP + OS, -OS - 90 + T = top - bot + m0, m1 = bot + 0.27 * T, top - 0.27 * T + s, h = 0.9 * g.S, 0.9 * g.H + g.ring(W * 0.04, m0, W * 0.96, m1, ts=s, th=h) + u0 = m1 - h + cu = (u0 + top) / 2 + g.ring(W * 0.06, u0, W * 0.98, top, [("r", -INF, cu + (top - cu) * 0.20)], ts=s, th=h) + l1 = m0 + h + cl = (bot + l1) / 2 + g.ring(W * 0.02, bot, W * 0.94, l1, [("l", cl - (cl - bot) * 0.20, INF)], ts=s, th=h) + + +@glyph("paragraph", 0xB6, 600, floor=3.6) +def _paragraph(g, W): + S, H = g.S, g.H + x2 = W - S + # Linear in W and S, so that the variable font does not drift. + x1 = 0.48 * W - 0.9 * S + yb = CAP * 0.40 + g.rect(x1, -110, x1 + S, CAP) + g.rect(x2, -110, W, CAP) + g.rect(x1, CAP - H, W, CAP) + # The bowl: half a filled superellipse with a rectangle to the stem. + cx = (x1 + S) * 0.5 + g.ring(0, yb, 2 * cx, CAP, [("r", -INF, INF)], ts=cx, th=(CAP - yb) / 2, cmin=0.0) + g.rect(cx - 1, yb, x1 + S, CAP) + + +MATH_A = 0.38 # half length of the math bars, in figure widths + + +@glyph("minus", 0x2212, FW, kind="tab") +def _minus(g, W): + a = W * MATH_A + g.rect(W / 2 - a, MATH_Y - g.H / 2, W / 2 + a, MATH_Y + g.H / 2) + + +@glyph("plusminus", 0xB1, FW, kind="tab") +def _plusminus(g, W): + a = W * MATH_A + yc = MATH_Y + 0.5 * g.H + 30 + arm = a * 0.92 + g.rect(W / 2 - a, yc - g.H / 2, W / 2 + a, yc + g.H / 2) + g.rect(W / 2 - g.S / 2, yc - arm, W / 2 + g.S / 2, yc + arm) + yb = yc - arm - 40 - 0.3 * g.H + g.rect(W / 2 - a, yb - g.H, W / 2 + a, yb) + + +@glyph("multiply", 0xD7, FW, kind="tab") +def _multiply(g, W): + import math + L = 2 * W * MATH_A * 0.92 + for ang in (math.pi / 4, -math.pi / 4): + bar_poly(g, W / 2, MATH_Y, L, 0.95 * g.S, ang) + + +@glyph("divide", 0xF7, FW, kind="tab") +def _divide(g, W): + a = W * MATH_A + g.rect(W / 2 - a, MATH_Y - g.H / 2, W / 2 + a, MATH_Y + g.H / 2) + d = dot_size(g) + gap = 36 + 0.25 * g.H + for y in (MATH_Y + g.H / 2 + gap, MATH_Y - g.H / 2 - gap - d): + g.rect(W / 2 - d / 2, y, W / 2 + d / 2, y + d) + + +# Numerators and denominators: the figures, drawn at FRAC_K of their size +# with a stroke of 0.62 S + 8, so that they keep their colour. +FRAC_K = 0.58 +FIGURES = ("zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine") +NUMR_W = 330 + + +def _small_figure(name, raise_y): + def draw(g, W): + from loom.geom import Canvas + k = FRAC_K + s_v = (0.62 * g.S + 8) / k + sub = Canvas(s_v, 0.84 * s_v) + GLYPHS[name].draw(sub, W / k) + for c in sub.contours: + g.add([(x * k, y * k + raise_y, on) for x, y, on in c]) + return draw + + +def _fraction_draw(g, W): + # The fraction slash reaches out of its box on both sides, so that + # numerator and denominator tuck under it. + ov = 0.55 * W + t = g.dt(W + 2 * ov, CAP) * 0.62 + g.par(W + ov - t / 2, CAP, -ov + t / 2, 0, t) + + +glyph("fraction", 0x2044, 120, kind="tab")(_fraction_draw) + +for _i, _fig in enumerate(FIGURES): + glyph(f"{_fig}.numr", None, NUMR_W, kind="tab")(_small_figure(_fig, CAP * (1 - FRAC_K))) + glyph(f"{_fig}.dnom", None, NUMR_W, kind="tab")(_small_figure(_fig, 0.0)) # ------------------------------------------------------------- notdef @@ -1287,114 +1607,299 @@ def _space(g, W): # --------------------------------------------------------------- marks +# +# 0.2: marks are lighter and smaller than in 0.1, so that they do not +# crowd the letters at Black. Their stroke grows more slowly than the +# stem (mark_stem). Capital marks stay below the typo ascender (950). +# Narrow bases (dotless i, I, l, dotless j) get ".narrow" marks that +# are no wider than the base plus 1.45 sidebearings, so that ï Ï Ĩ ĺ do +# not widen their base. + +def mark_stem(S: float) -> float: + """Stroke of the marks: 0.62 S + 12 (S at Thin, 0.69 S at Black).""" + return 0.62 * S + 12 + def accent_gap(S: float, case: str) -> float: - return (44 + 0.30 * S) if case == "lc" else (36 + 0.24 * S) + """Gap between the x-height (or cap height) and the mark.""" + return (40 + 0.22 * S) if case == "lc" else (30 + 0.16 * S) def accent_size(S: float, case: str) -> float: - """Height of the acute, grave and circumflex.""" - h = 150 + 0.35 * S - return h if case == "lc" else h * 0.78 + """Height of the acute, grave, circumflex and caron.""" + h = 140 + 0.20 * S + return h if case == "lc" else h * 0.72 + + +def narrow_width(S: float) -> float: + """Largest ink width of a .narrow mark: the stem plus 1.45 sidebearings.""" + return S + 1.45 * (22 + 0.22 * S) def _accent_box(p, kind) -> float: - """Width of the spacing accent glyphs: the mark plus a small margin.""" + """Width of the spacing grave (U+0060): the mark plus a small margin.""" h = accent_size(p.S, "lc") - t = 0.9 * p.S * (1 + (0.55) ** 2) ** 0.5 - return 0.55 * h + t + sm = mark_stem(p.S) + dx = 0.6 * h + t = 0.9 * sm * (dx * dx + h * h) ** 0.5 / h + return dx + t + +def _slant_dims(sm, h, wmax=None, k=0.6): + """(dx, t) of an acute: run dx over height h, horizontal width t. + + The ink is dx + t wide. With wmax, dx shrinks until the ink fits. + """ + import math + dx = k * h + t = 0.9 * sm * math.hypot(dx, h) / h + if wmax is not None and dx + t > wmax: + for _ in range(6): + dx = max(0.12 * h, wmax - t) + t = 0.9 * sm * math.hypot(dx, h) / h + return dx, t -def _accent(g, kind, case, cx): + +def _accent(g, kind, case, cx, narrow=False): """Draw a mark with its ink centred on x = cx. lc marks sit above the x-height, case marks above the cap height. - The cedilla hangs below the baseline. + Marks below the baseline (cedilla, ogonek, comma) and the vertical + caron (caroncomb.alt) have their own rules. """ + import math S, H = g.S, g.H + sm = mark_stem(S) + hm = 0.84 * sm base_y = (XH if case == "lc" else CAP) + accent_gap(S, case) h = accent_size(S, case) - s = S * 0.9 - hh = H * 0.9 + wmax = narrow_width(S) if narrow else None + + def fit(w): + return min(w, wmax) if wmax is not None else w + if kind in ("acute", "grave"): - dx = 0.55 * h - t = g.dt(dx, h) * 0.9 + dx, t = _slant_dims(sm, h, wmax) if kind == "acute": g.par(cx - dx / 2, base_y, cx + dx / 2, base_y + h, t) else: g.par(cx + dx / 2, base_y, cx - dx / 2, base_y + h, t) - elif kind == "circumflex": - w = 1.6 * h + 0.3 * S - t = g.dt(w / 2, h) * 0.78 - _caret(g, cx, w, base_y, base_y + h, t) + elif kind == "hungarumlaut": + dx, t = _slant_dims(sm * 0.9, h, None, 0.5) + sep = t + 0.45 * t + 24 + for o in (-sep / 2, sep / 2): + g.par(cx + o - dx / 2, base_y, cx + o + dx / 2, base_y + h, t) + elif kind in ("circumflex", "caron"): + w = fit(1.5 * h + 0.3 * sm) + t = 0.9 * sm * math.hypot(w / 2, h) / h * 0.8 + if kind == "circumflex": + _caret(g, cx, w, base_y, base_y + h, t) + else: + g.vee(cx - w / 2, cx + w / 2, base_y + h, base_y, t) elif kind == "dieresis": - d = S * 0.95 - gap = 0.6 * d + 40 + d = 0.95 * sm + gap = 0.45 * d + 30 + if wmax is not None and 2 * d + gap > wmax: + gap = 0.3 * wmax + d = min(d, (wmax - gap) / 2) g.rect(cx - gap / 2 - d, base_y, cx - gap / 2, base_y + d) g.rect(cx + gap / 2, base_y, cx + gap / 2 + d, base_y + d) + elif kind == "dotaccent": + d = 0.95 * sm + g.rect(cx - d / 2, base_y, cx + d / 2, base_y + d) elif kind == "tilde": - ts, tt = S * 0.62, H * 0.55 - th = (tt + 20 + 0.04 * S) if case == "lc" else (tt + 16 + 0.03 * S) - w = 5.0 * th + 0.4 * ts - _tilde_shape(g, cx, base_y + th, w, th, ts, tt) + ts, tt = 0.78 * sm, 0.62 * sm + amp = (tt + 18 + 0.04 * sm) if case == "lc" else (tt + 14 + 0.03 * sm) + w = fit(5.0 * amp + 0.4 * ts) + _tilde_shape(g, cx, base_y + amp, w, amp, ts, tt) + elif kind == "macron": + w = fit(0.9 * h + 0.6 * sm) + g.rect(cx - w / 2, base_y, cx + w / 2, base_y + 0.8 * hm) + elif kind == "breve": + w = fit(1.25 * h + 0.3 * sm) + hb = 0.62 * h + cy = base_y + hb + g.ring(cx - w / 2, base_y, cx + w / 2, base_y + 2 * hb, + [("l", cy, INF), ("r", cy, INF)], ts=0.8 * sm, th=0.7 * sm) elif kind == "ring": - d = (150 + 0.9 * S) if case == "lc" else (130 + 0.8 * S) - g.ring(cx - d / 2, base_y, cx + d / 2, base_y + d, ts=S * 0.75, th=H * 0.75) + if case == "lc": + w, hr = 150 + 0.6 * sm, 150 + 0.6 * sm + else: + w, hr = 140 + 0.55 * sm, 110 + 0.45 * sm + g.ring(cx - w / 2, base_y, cx + w / 2, base_y + hr, ts=0.75 * sm, th=0.62 * sm) elif kind == "cedilla": - s2 = S * 0.8 + s2 = 0.85 * sm g.rect(cx - s2 / 2, -60, cx + s2 / 2, 12) - r = 70 + 0.45 * S + r = 62 + 0.45 * sm y1 = -40 y0 = y1 - 2 * r * 0.85 - g.ring(cx - r, y0, cx + r, y1, [("l", -INF, INF)], ts=s2, th=H * 0.8) + g.ring(cx - r, y0, cx + r, y1, [("l", -INF, INF)], ts=s2, th=0.8 * hm) + elif kind == "ogonek": + # The attachment point is the origin: the tail hangs from the + # base's lower right and curls back to the right. + wo = 120 + 0.6 * sm + ho = 2 * (100 + 0.32 * sm) + y1 = 0.9 * hm + cy = y1 - ho / 2 + x1 = 0.0 + g.ring(x1 - wo, y1 - ho, x1, y1, [("r", cy - 0.45 * ho / 2, INF)], + ts=0.85 * sm, th=0.72 * sm) + elif kind == "commaaccent": + d = 0.9 * sm + top = -(36 + 0.16 * S) + comma_shape_d(g, cx - d * 0.55, top - d, d) + elif kind == "commaturnedabove": + d = 0.9 * sm + y = base_y + # A comma turned by 180 degrees: the square on top. + g.rot(2 * cx, y + d * 1.0, lambda: comma_shape_d(g, cx - d * 0.55, y + d * 0.95, d)) + elif kind == "caronalt": + # The vertical caron of ď ľ ť Ľ: a comma with its top at y = 0, + # its ink starting at x = 0. + d = 0.85 * sm + comma_shape_d(g, 0, -d, d, tail=0.9) else: raise KeyError(kind) -ACCENT_KINDS = ("acute", "grave", "circumflex", "dieresis", "tilde", "ring", "cedilla") +def comma_shape_d(g, x, y, d, tail=1.1): + """A comma of size d with its square at (x, y).""" + g.rect(x, y, x + d, y + d) + g.poly([(x + d * 0.25, y), (x + d, y), (x + d * 0.55, y - d * tail), (x + d * 0.1, y - d * tail)]) + + +TOP_KINDS = ("grave", "acute", "circumflex", "tilde", "macron", "breve", "dotaccent", + "dieresis", "ring", "hungarumlaut", "caron") +NARROW_KINDS = ("grave", "acute", "circumflex", "tilde", "macron", "breve", "dieresis") +BOTTOM_KINDS = ("cedilla", "ogonek", "commaaccent") def accent_names() -> list[str]: out = [] - for kind in ACCENT_KINDS: - if kind == "cedilla": - out.append("cedillacomb") - else: - out += [f"{kind}comb", f"{kind}comb.case"] + for kind in TOP_KINDS: + out += [f"{kind}comb", f"{kind}comb.case"] + if kind in NARROW_KINDS: + out += [f"{kind}comb.narrow", f"{kind}comb.case.narrow"] + out += [f"{k}comb" for k in BOTTOM_KINDS] + out += ["commaturnedabovecomb", "caroncomb.alt"] return out -def draw_accent(g, name: str) -> None: - kind = name.replace("comb", "").replace(".case", "") - case = "case" if name.endswith(".case") else "lc" - _accent(g, kind, case, 0.0) +def mark_kind(name: str) -> tuple[str, str, bool]: + """Return (kind, case, narrow) of a mark glyph name.""" + if name == "caroncomb.alt": + return "caronalt", "lc", False + parts = name.split(".") + kind = parts[0][: -len("comb")] + return kind, ("case" if "case" in parts else "lc"), ("narrow" in parts) + +def draw_accent(g, name: str) -> None: + kind, case, narrow = mark_kind(name) + _accent(g, kind, case, 0.0, narrow) -# Composites: name -> (unicode, base glyph, accent glyph). -_MARK = {"grave": 0x300, "acute": 0x301, "circumflex": 0x302, "tilde": 0x303, - "dieresis": 0x308, "ring": 0x30A, "cedilla": 0x327} +# ------------------------------------------------------------ composites +# +# A composite is a base glyph plus marks, or a row of glyphs (IJ, ʼn, ½). +# loom/build.py places the parts (see build.composite). -def _composites() -> dict[str, tuple[int, str, str]]: +@dataclass(frozen=True) +class Comp: + cp: int + base: str + # (mark glyph, place). Places: "top" (centred on the base's ink, or + # on its top anchor), "top-asc" (a capital mark lifted to the + # ascender: ĥ ĺ), "bottom" (centred below), "ogonek" (at the base's + # lower right), "right" (the vertical caron, right of the stem), + # "dot" (the middle dot of Ŀ ŀ). + marks: tuple = () + # A row of glyphs (the base is the first one), set by advance. + row: tuple = () + + +_TOP = {0x300: "grave", 0x301: "acute", 0x302: "circumflex", 0x303: "tilde", 0x304: "macron", + 0x306: "breve", 0x307: "dotaccent", 0x308: "dieresis", 0x30A: "ring", + 0x30B: "hungarumlaut", 0x30C: "caron"} +_BOTTOM = {0x327: "cedilla", 0x328: "ogonek", 0x326: "commaaccent"} +# Bases with an ascender: a top mark is a capital mark, lifted. +ASCENDER_BASES = {"h", "l", "k", "b", "d"} +# Narrow bases: top marks are .narrow marks. +NARROW_BASES = {"dotlessi", "I", "l", "dotlessj"} +# Letters whose cedilla is a comma below (Latvian, Romanian). +_COMMA_BELOW = {0x122, 0x136, 0x137, 0x13B, 0x13C, 0x145, 0x146, 0x156, 0x157} +# Vertical caron (ď ľ ť Ľ). +_CARON_ALT = {0x10F, 0x13D, 0x13E, 0x165} + + +def _composites() -> dict[str, Comp]: import unicodedata out = {} - for cp in list(range(0xC0, 0x100)) + [0x178]: + cps = list(range(0xC0, 0x100)) + list(range(0x100, 0x180)) + [0x218, 0x219, 0x21A, 0x21B] + for cp in cps: ch = chr(cp) decomp = unicodedata.decomposition(ch).split() if len(decomp) != 2 or decomp[0].startswith("<"): continue base, mark = chr(int(decomp[0], 16)), int(decomp[1], 16) - kind = next((k for k, v in _MARK.items() if v == mark), None) - if kind is None: + if base not in "".join(chr(u) for u in cmap_drawn()): continue - base_name = {"i": "dotlessi"}.get(base, base) case = base.isupper() - acc = "cedillacomb" if kind == "cedilla" else (f"{kind}comb.case" if case else f"{kind}comb") - name = unicodedata.name(ch) - out[_agl_name(cp)] = (cp, base_name, acc) + base_name = _agl_name(ord(base)) + if cp == 0x123: # ģ: a turned comma above + out[_agl_name(cp)] = Comp(cp, "g", (("commaturnedabovecomb", "top"),)) + continue + if mark in _TOP: + kind = _TOP[mark] + if cp in _CARON_ALT: + out[_agl_name(cp)] = Comp(cp, base_name, (("caroncomb.alt", "right"),)) + continue + if base == "i": + base_name = "dotlessi" + elif base == "j": + base_name = "dotlessj" + place = "top" + mark_name = f"{kind}comb" + if case or base_name in ASCENDER_BASES: + mark_name += ".case" + place = "top" if case else "top-asc" + if base_name in NARROW_BASES and kind in NARROW_KINDS: + mark_name += ".narrow" + out[_agl_name(cp)] = Comp(cp, base_name, ((mark_name, place),)) + elif mark in _BOTTOM: + kind = _BOTTOM[mark] + if cp in _COMMA_BELOW: + kind = "commaaccent" + place = "ogonek" if kind == "ogonek" else "bottom" + out[_agl_name(cp)] = Comp(cp, base_name, ((f"{kind}comb", place),)) + # Compatibility letters: rows of glyphs, and the middle dot. + out["IJ"] = Comp(0x132, "I", row=("I", "J")) + out["ij"] = Comp(0x133, "i", row=("i", "j")) + out["napostrophe"] = Comp(0x149, "quoteright", row=("quoteright", "n")) + out["Ldot"] = Comp(0x13F, "L", (("periodcentered", "dot"),)) + out["ldot"] = Comp(0x140, "l", (("periodcentered", "dot"),)) + # Fractions. + out["onehalf"] = Comp(0xBD, "one.numr", row=("one.numr", "fraction", "two.dnom")) + out["onequarter"] = Comp(0xBC, "one.numr", row=("one.numr", "fraction", "four.dnom")) + out["threequarters"] = Comp(0xBE, "three.numr", row=("three.numr", "fraction", "four.dnom")) + # Spacing marks: one mark, centred in its own advance. + for cp, mark in SPACING_MARKS.items(): + out[_agl_name(cp)] = Comp(cp, mark, row=(mark,)) return out +# Spacing forms of the marks (the spacing grave U+0060 is drawn). +SPACING_MARKS = { + 0xA8: "dieresiscomb", 0xAF: "macroncomb", 0xB4: "acutecomb", 0xB8: "cedillacomb", + 0x2C6: "circumflexcomb", 0x2C7: "caroncomb", 0x2D8: "brevecomb", 0x2D9: "dotaccentcomb", + 0x2DA: "ringcomb", 0x2DB: "ogonekcomb", 0x2DC: "tildecomb", 0x2DD: "hungarumlautcomb", +} + + +def cmap_drawn() -> list[int]: + return [u for gl in GLYPHS.values() for u in gl.unicodes] + + def _agl_name(cp: int) -> str: from fontTools.agl import UV2AGL return UV2AGL.get(cp, f"uni{cp:04X}") @@ -1406,8 +1911,8 @@ def _agl_name(cp: int) -> str: def glyph_order() -> list[str]: """.notdef, space, the drawn glyphs by code point, composites, marks.""" drawn = [n for n in GLYPHS if n not in (".notdef", "space")] - drawn.sort(key=lambda n: GLYPHS[n].unicodes[0] if GLYPHS[n].unicodes else 0x10FFFF) - comps = sorted(COMPOSITES, key=lambda n: COMPOSITES[n][0]) + drawn.sort(key=lambda n: (GLYPHS[n].unicodes[0] if GLYPHS[n].unicodes else 0x10FFFF, n)) + comps = sorted(COMPOSITES, key=lambda n: COMPOSITES[n].cp) return [".notdef", "space"] + drawn + comps + accent_names() @@ -1416,8 +1921,8 @@ def cmap() -> dict[int, str]: for n, gl in GLYPHS.items(): for u in gl.unicodes: out[u] = n - for n, (u, _, _) in COMPOSITES.items(): - out[u] = n + for n, c in COMPOSITES.items(): + out[c.cp] = n return out diff --git a/heddle/loom/kerning.py b/heddle/loom/kerning.py index 9a07e42..1134b79 100644 --- a/heddle/loom/kerning.py +++ b/heddle/loom/kerning.py @@ -1,7 +1,11 @@ """Heddle kerning: a small class kerning for display use. Classes cover the pairs that open up most in headlines: A V W Y T L P F -against round lowercase and capitals, and punctuation. Values are in +against round lowercase and capitals, and punctuation. 0.2 adds +lowercase pairs (v w y k x r f t against round letters) and figures +against punctuation (7 and the round figures before . and ,). The +default figures are tabular, so figures do not kern with each other +(Fontspector tabular_kerning). Values are in font units at wdth 100 and scale with the width axis. Tests check that no kerned pair makes two glyphs touch, in any master. @@ -16,8 +20,8 @@ def _with_accents(*bases: str) -> list[str]: out = list(bases) - for name, (_, base, _acc) in sorted(G.COMPOSITES.items()): - if base in bases: + for name, c in sorted(G.COMPOSITES.items()): + if c.base in bases and not c.row: out.append(name) return out @@ -33,8 +37,15 @@ def _with_accents(*bases: str) -> list[str]: "P": ["P", "Thorn"], "F": ["F"], "O": _with_accents("O", "D", "Q") + ["Oslash", "Eth"], - "v": ["v", "w", "y"] + _with_accents("y")[1:], - "r": ["r"], + "v": _with_accents("v", "w", "y"), + "r": _with_accents("r"), + # 0.2: lowercase and figures. + "or": _with_accents("o", "b", "p") + ["oslash", "thorn"], + "f": ["f"], + "k": ["k", "x", "kgreenlandic"] + _with_accents("k")[1:], + "t": _with_accents("t")[1:] + ["t", "tbar"], + "seven": ["seven"], + "figround": ["zero", "six", "eight", "nine", "three"], } # Right side of a pair: the glyph's left side. @@ -49,6 +60,9 @@ def _with_accents(*bases: str) -> list[str]: "T": ["T"], "period": ["period", "comma", "ellipsis", "quotesinglbase", "quotedblbase"], "hyphen": ["hyphen", "endash", "emdash"], + # 0.2: lowercase and figures. + "v": _with_accents("v", "w", "y"), + "x": ["x"], } # (left class, right class): value at wdth 100. @@ -66,6 +80,12 @@ def _with_accents(*bases: str) -> list[str]: ("O", "period"): -25, ("v", "period"): -45, ("r", "period"): -40, ("T", "hyphen"): -40, ("V", "hyphen"): -30, ("W", "hyphen"): -20, ("Y", "hyphen"): -40, + # 0.2: lowercase pairs. + ("v", "o"): -20, ("v", "a"): -15, ("or", "v"): -20, ("or", "x"): -15, + ("r", "o"): -15, ("r", "a"): -12, ("f", "o"): -15, ("f", "a"): -10, ("f", "period"): -35, + ("k", "o"): -20, ("t", "o"): -10, ("v", "v"): -10, + # 0.2: figures. + ("seven", "period"): -70, ("seven", "hyphen"): -20, ("figround", "period"): -15, } diff --git a/heddle/pyproject.toml b/heddle/pyproject.toml index 77623ca..894624e 100644 --- a/heddle/pyproject.toml +++ b/heddle/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "heddle-loom" -version = "0.1.0" +version = "0.2.0" description = "Generator for the machine-made Heddle typeface." requires-python = ">=3.13" license = "Apache-2.0" @@ -12,6 +12,7 @@ dependencies = [ "pillow==13.0.0", "munkres==1.1.4", "brotli==1.2.1", + "ttfautohint-py==0.6.1", ] [project.optional-dependencies] diff --git a/heddle/scripts/build.sh b/heddle/scripts/build.sh index 2dfa8e4..4a86523 100755 --- a/heddle/scripts/build.sh +++ b/heddle/scripts/build.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build Heddle 0.1: UFO masters and statics, then the variable TTF, the +# Build Heddle 0.2: UFO masters and statics, then the variable TTF, the # static TTF and WOFF2. # Usage: heddle/scripts/build.sh (from any directory) set -euo pipefail @@ -24,6 +24,8 @@ python tools/finish.py --variable "$OUT/variable/$VF" # Static fonts. The generator removed the overlaps already. fontmake -u build/static/*.ufo -o ttf --keep-overlaps --output-dir "$OUT/ttf" python tools/finish.py --static "$OUT"/ttf/*.ttf +# Hinting (statics only; ttfautohint does not hint variable fonts). +python tools/finish.py --hint "$OUT"/ttf/*.ttf python tools/finish.py --woff2-dir "$OUT/webfonts" --woff2 \ "$OUT/variable/$VF" \ diff --git a/heddle/tests/test_heddle.py b/heddle/tests/test_heddle.py index 395affa..8a8cf75 100644 --- a/heddle/tests/test_heddle.py +++ b/heddle/tests/test_heddle.py @@ -1,5 +1,6 @@ -"""Tests for the Heddle 0.1 generator: metrics, glyph set, masters, -the port of the sketch engine, kerning, drift, statics, names.""" +"""Tests for the Heddle generator (0.2): metrics, glyph set, masters, +the port of the sketch engine, marks, counters, kerning, features, +drift, statics, names.""" from __future__ import annotations @@ -105,9 +106,44 @@ def test_glyph_order_and_names(): def test_composites_use_existing_glyphs(): marks = set(G.accent_names()) - for name, (cp, base, acc) in G.COMPOSITES.items(): - assert base in G.GLYPHS and acc in marks, name - assert G.cmap()[cp] == name + for name, c in G.COMPOSITES.items(): + assert c.base in G.GLYPHS or c.base in marks, name + for mark, place in c.marks: + assert mark in marks or mark in G.GLYPHS, name + for part in c.row: + assert part in G.GLYPHS or part in marks, name + assert G.cmap()[c.cp] == name + + +def test_latin_extended_a_and_symbols(): + cps = set(ENCODED) + assert set(range(0x100, 0x180)) <= cps + for ch in "ȘșȚț¢£¥§¶±×÷½¼¾⁄−": + assert ord(ch) in cps, ch + + +CE_SAMPLES = { + "Polish": "Zażółć gęślą jaźń ŁÓDŹ", + "Czech": "Příliš žluťoučký kůň úpěl ďábelské ódy", + "Slovak": "Kŕdeľ šťastných ďatľov učí pri ústí Váhu mĺkveho koňa", + "Hungarian": "Árvíztűrő tükörfúrógép ŐŰ", + "Romanian": "Șirul țărilor înșiră ÎȘȚ", + "Turkish": "Pijamalı hasta yağız şoföre çabucak güvendi İĞŞ", + "Croatian": "Đurđa čuči žđ ĆČŠŽĐ", + "Slovenian": "Ščinkavec žvižga ŠČŽ", + "Latvian": "Glāžšķūņa rūķīši ĢĶĻŅ ģķļņ", + "Lithuanian": "Įlinkusi ąžuolų šaka ŲŪĖ ųūė", + "Estonian": "Põdur Zagrebi tšellomängija-följetonist Ciqo külmetas", + "Maltese": "Ċirku ħabib Ġużeppi Żebbuġ Ħ", + "Esperanto": "Ĉiuĵaŭde eĥoŝanĝo ĜĤĴŜŬ", +} + + +@pytest.mark.parametrize("lang", sorted(CE_SAMPLES)) +def test_central_european_text_is_covered(lang): + cm = G.cmap() + missing = {ch for ch in CE_SAMPLES[lang] if ch != " " and ord(ch) not in cm} + assert not missing, f"{lang}: {missing}" def test_space_and_nbsp_share_a_glyph(): @@ -254,19 +290,108 @@ def _outline(name, w, d): @pytest.mark.parametrize("loc", MASTERS) def test_kerned_pairs_do_not_touch(loc): + # Every member of every class, accented letters included, in every master. w, d = loc kern = K.kerning(G.Params(w, d)) - reps = {k: v[0] for k, v in {**{f"1{k}": v for k, v in K.KERN1.items()}, - **{f"2{k}": v for k, v in K.KERN2.items()}}.items()} + prof = {} + + def get(name, side): + if (name, side) not in prof: + cs, adv = _outline(name, w, d) + prof[(name, side)] = (_profile(cs, side), adv) + return prof[(name, side)] + for (a, b), v in kern.items(): - left = reps["1" + a.split(".")[-1]] - right = reps["2" + b.split(".")[-1]] - lc, ladv = _outline(left, w, d) - rc, _ = _outline(right, w, d) - lp, rp = _profile(lc, "right"), _profile(rc, "left") - gaps = [ladv + v + rp[y] - lp[y] for y in lp if y in rp] - if gaps: - assert min(gaps) >= 12, f"{left}{right} at {loc}: gap {min(gaps):.0f}" + for left in K.KERN1[a.split(".")[-1]]: + for right in K.KERN2[b.split(".")[-1]]: + lp, ladv = get(left, "right") + rp, _ = get(right, "left") + gaps = [ladv + v + rp[y] - lp[y] for y in lp if y in rp] + if gaps: + assert min(gaps) >= 12, f"{left}{right} at {loc}: gap {min(gaps):.0f}" + + +def test_kerning_covers_lowercase_and_figures(): + lefts = {a for a, _ in K.PAIRS} + assert {"v", "r", "or", "f", "k", "t", "seven", "figround"} <= lefts + tab = {n for n, gl in G.GLYPHS.items() if gl.kind == "tab"} + for a, b in K.PAIRS: + # Tabular glyphs do not kern with each other. + assert not (set(K.KERN1[a]) & tab and set(K.KERN2[b]) & tab), (a, b) + + +# ------------------------------------------------------------- marks (0.2) + +NARROW_CASES = ["idieresis", "Idieresis", "iacute", "Iacute", "icircumflex", "Icircumflex", + "igrave", "Igrave", "itilde", "Itilde", "imacron", "Imacron", "ibreve", "Ibreve", + "lacute"] + + +@pytest.mark.parametrize("loc", MASTERS) +def test_narrow_bases_keep_their_advance(loc): + for name in NARROW_CASES: + _, adv = B.composite(name, *loc) + assert adv == pytest.approx(B.drawn(G.COMPOSITES[name].base, *loc)[1]), name + + +def test_capital_marks_stay_below_the_typo_ascender(): + for name, c in G.COMPOSITES.items(): + if c.row or not c.base[:1].isupper(): + continue + for w, d in MASTERS: + cs, _ = B.direct_outline(name, w, d) + top = max(y for c_ in cs for _, y, _ in c_) + assert top <= B.TYPO_ASCENDER, (name, w, d, top) + + +def test_marks_are_lighter_than_0_1(): + # 0.1 dieresis at Black: dots of 0.95 S (169 units). 0.2: 0.95 * mark_stem. + cs, _ = B.drawn("dieresiscomb", 900, 100) + xs = [x for c in cs for x, _, _ in c] + assert max(xs) - min(xs) < 330 + + +# ----------------------------------------------------------- counters (0.2) + +@pytest.mark.parametrize("name", ["a", "f", "j", "s", "t", "germandbls", "eth", "at", + "copyright", "registered", "percent", "ampersand"]) +def test_small_counters_stay_open(name): + for w, d in MASTERS: + geom.CLOSED.clear() + B.drawn.cache_clear() + B.drawn(name, w, d) + assert not geom.CLOSED, f"{name} at {(w, d)}: a counter closes" + + +@pytest.mark.parametrize("name", ["f", "t", "j"]) +def test_hooks_have_an_open_counter_at_black(name): + # The visible counter between the stem and the terminal is at least + # two minimum counter radii wide. + p = G.Params(900, 100) + g = geom.Canvas(p.S, p.H) + wr, ts = G.hook_span(g, 0) + assert wr - p.S - ts >= 2 * geom.counter_min(p.S) - 1e-9 + + +def test_counter_rule_is_clamped_per_weight(): + assert geom.counter_min(28) == pytest.approx(7) + assert geom.counter_min(178) == pytest.approx(geom.CMIN_MAX) + + +# ----------------------------------------------------------- features (0.2) + +def test_frac_feature_compiles_and_substitutes(): + from fontTools.feaLib.builder import addOpenTypeFeaturesFromString + from fontTools.fontBuilder import FontBuilder + + order = G.glyph_order() + fb = FontBuilder(1000, isTTF=True) + fb.setupGlyphOrder(order) + fb.setupCharacterMap(G.cmap()) + addOpenTypeFeaturesFromString(fb.font, B.features()) + gsub = fb.font["GSUB"].table + tags = {fr.FeatureTag for fr in gsub.FeatureList.FeatureRecord} + assert {"frac", "numr", "dnom"} <= tags # ------------------------------------------------------------------- drift @@ -331,11 +456,27 @@ def test_static_union_matches_nonzero_fill(black): assert R.mismatch(R.nonzero_mask(cs), R.path_mask(path)) == 0, name -def test_robust_union_fixes_the_spine_of_S(): - # pathops alone drops the upper ring here (curves nearly coincide). - cs, _ = B.direct_outline("S", 400, 120) - p = B.robust_union(cs) - assert R.mismatch(R.nonzero_mask(cs), R.path_mask(p)) == 0 +@pytest.mark.parametrize("name,loc", [("S", (400, 120)), ("OE", (800, 100)), ("oslash", (700, 70)), + ("b", (900, 100)), ("dcroat", (800, 100))]) +def test_clean_union_without_fallback(name, loc): + # 0.1 needed fallbacks here: the S spine (jitter) and rounded paths. + # 0.2 unions, snaps, splits self-touching contours and orients them. + cs, _ = B.interpolated(name, *loc) + out = B.clean_union(name, cs) + contours = B._seg_contours(out) + assert R.mismatch(R.nonzero_mask(cs), R.path_mask(B._to_path(contours))) == 0 + # Overlap-free and wound for nonzero: outer loops CCW, counters CW, + # so the signed area equals the ink area. + ink = R.count(R.nonzero_mask(cs)) * R.PX * R.PX + assert abs(B._flat_area(out) - ink) / ink < 0.05 + for c in out: + for op, pts in c: + assert all(isinstance(v, int) for pt in pts for v in pt) + + +def test_build_has_no_overlap_fallback(): + src = (HEDDLE / "loom" / "build.py").read_text() + assert "robust_union" not in src and "jitter" not in src # ------------------------------------------------------------------- names diff --git a/heddle/tools/drift.py b/heddle/tools/drift.py index 124c094..516df43 100644 --- a/heddle/tools/drift.py +++ b/heddle/tools/drift.py @@ -42,6 +42,8 @@ (250, 100), (550, 100), (800, 100), (100, 85), (400, 85), (700, 85), (900, 85), (100, 120), (400, 120), (700, 120), (900, 120), (300, 70), (500, 100), (600, 140), (850, 70), + # 0.2: wdth 85 is a master now; these sit between 70, 85 and 100. + (800, 78), (900, 78), (900, 92), (650, 92), ] diff --git a/heddle/tools/finish.py b/heddle/tools/finish.py index cc18f40..0de4f55 100644 --- a/heddle/tools/finish.py +++ b/heddle/tools/finish.py @@ -3,6 +3,7 @@ Usage: python tools/finish.py --variable FONT.ttf python tools/finish.py --static FONT.ttf ... + python tools/finish.py --hint FONT.ttf ... python tools/finish.py --woff2 FONT.ttf ... [--woff2-dir DIR] --variable: set the OVERLAP_SIMPLE flag on every simple glyph and the @@ -11,6 +12,10 @@ --static: add a STAT table that names the position of the static on the wght and wdth axes (one value per axis, as the OpenType spec shows for static fonts). +--hint: hint static TTFs with ttfautohint (pinned: ttfautohint-py 0.6.1, + ttfautohint 1.8.4). No version string goes into the font + (no_info), and the head dates stay as fontmake set them from + SOURCE_DATE_EPOCH, so the hinted build stays byte-reproducible. --woff2: write a WOFF2 copy. Apache-2.0. @@ -63,6 +68,22 @@ def finish_static(path: Path) -> None: print(f"Added STAT to {path}.") +def hint(path: Path) -> None: + import io + + from ttfautohint import ttfautohint + + before = TTFont(path) + created, modified = before["head"].created, before["head"].modified + data = ttfautohint(in_file=str(path), no_info=True, hint_composites=False) + font = TTFont(io.BytesIO(data)) + font["head"].created, font["head"].modified = created, modified + # Hinted fonts: instructions may depend on integer ppem sizes. + font["head"].flags |= 1 << 3 + font.save(path) + print(f"Hinted {path}.") + + def write_woff2(path: Path, out_dir: Path) -> None: font = TTFont(path) font.flavor = "woff2" @@ -76,15 +97,18 @@ def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description="Finish the compiled Heddle fonts.") ap.add_argument("--variable", nargs="*", default=[], type=Path) ap.add_argument("--static", nargs="*", default=[], type=Path) + ap.add_argument("--hint", nargs="*", default=[], type=Path) ap.add_argument("--woff2", nargs="*", default=[], type=Path) ap.add_argument("--woff2-dir", type=Path, help="Output folder for WOFF2 files.") args = ap.parse_args(argv) - if not (args.variable or args.static or args.woff2): + if not (args.variable or args.static or args.hint or args.woff2): ap.error("Give at least one font file.") for p in args.variable: finish_variable(p) for p in args.static: finish_static(p) + for p in args.hint: + hint(p) for p in args.woff2: write_woff2(p, args.woff2_dir or p.parent) return 0 diff --git a/heddle/tools/proof.py b/heddle/tools/proof.py index dd54b31..d14cbfb 100644 --- a/heddle/tools/proof.py +++ b/heddle/tools/proof.py @@ -1,4 +1,4 @@ -"""Render the Heddle 0.1 proof sheets from the compiled variable font. +"""Render the Heddle proof sheets from the compiled variable font. Usage: python tools/proof.py [--font VF.ttf] [--out-dir ../proofs/heddle] @@ -12,6 +12,16 @@ edges, midpoints): checks interpolation heddle-kerning.png kerned pairs, kerning off and on heddle-accents.png accented letters, marks and symbols + heddle-latin-ext.png Latin Extended-A and Central European text + heddle-symbols.png 0.2 symbols, fractions (frac off and on), + the redrawn ß ð @ © ® % + +With --before OLD_VF (for example Heddle 0.1 from git), it also writes +before/after sheets (old upper line, new lower line): + + heddle-before-after-accents.png marks at Black and Regular + heddle-before-after-counters.png hooks and small rings at Black + heddle-before-after-drift.png the glyphs that drifted most near wdth 85 The sketch comparison is tools/sketch_compare.py (needs Chromium). @@ -43,6 +53,8 @@ "To Ta Te Vo Va Wo Wa Yo Ya Po Pa Fo Fa", "LT LV LW LY AV AW AY AT OA OV OY", "T. V. W. Y. P. F, v. y, r. T- Y-", + "vow eye yo ox ko to fo fa ro ra f. vv", + "1958. 7. 3. 0. 7- 2027.", ] @@ -101,7 +113,7 @@ def encoded_chars() -> list[str]: def charset(vf: str, out: Path) -> None: chars = encoded_chars() - sheet = Sheet(f"machine-made Heddle 0.1: character set ({len(chars)} characters and the space)") + sheet = Sheet(f"machine-made Heddle 0.2: character set ({len(chars)} characters and the space)") size, cols = 52, 30 for wght, wdth in ((400, 100), (900, 70), (100, 140)): sheet.text(f"wght {wght}, wdth {wdth}") @@ -114,14 +126,14 @@ def charset(vf: str, out: Path) -> None: def weight_waterfall(vf: str, out: Path) -> None: - sheet = Sheet("machine-made Heddle 0.1: weight waterfall at wdth 100 (the nine static weights)") + sheet = Sheet("machine-made Heddle 0.2: weight waterfall at wdth 100 (the nine static weights)") for wght, style in build.WEIGHTS: sheet.line("Hamburgefonstiv 1958", font_at(vf, 80, wght, 100), 80, f"{wght} {style}") sheet.save(out) def width_ladder(vf: str, out: Path) -> None: - sheet = Sheet("machine-made Heddle 0.1: width ladder (wdth 70 to 140)") + sheet = Sheet("machine-made Heddle 0.2: width ladder (wdth 70 to 140)") for wght in (400, 900): sheet.text(f"wght {wght}") for wdth, name, _ in build.WIDTHS: @@ -131,7 +143,7 @@ def width_ladder(vf: str, out: Path) -> None: def designspace(vf: str, out: Path) -> None: - sheet = Sheet("machine-made Heddle 0.1: designspace grid (masters at wght 100, 400, 700, 900 x wdth 70, 100, 140)") + sheet = Sheet("machine-made Heddle 0.2: designspace grid (masters at wght 100, 400, 700, 900 x wdth 70, 85, 100, 140)") for wdth in (70, 85, 105, 120, 140): sheet.text(f"wdth {wdth}") for wght in (100, 250, 550, 800, 900): @@ -140,19 +152,20 @@ def designspace(vf: str, out: Path) -> None: def kerning(vf: str, out: Path) -> None: - sheet = Sheet("machine-made Heddle 0.1: kerning, off (upper line) and on (lower line), wght 700") + sheet = Sheet("machine-made Heddle 0.2: kerning, off (upper line) and on (lower line), wght 700") for wdth in (70, 100, 140): sheet.text(f"wdth {wdth}") - font = font_at(vf, 64, 700, wdth) + size = 64 if wdth < 140 else 50 + font = font_at(vf, size, 700, wdth) for s in KERN_LINES: - sheet.line(s, font, 64, "kern off", features=["-kern"]) - sheet.line(s, font, 64, "kern on", features=["kern"]) + sheet.line(s, font, size, "kern off", features=["-kern"]) + sheet.line(s, font, size, "kern on", features=["kern"]) sheet.gap() sheet.save(out) def accents(vf: str, out: Path) -> None: - sheet = Sheet("machine-made Heddle 0.1: Latin-1 letters, marks and symbols") + sheet = Sheet("machine-made Heddle 0.2: Latin-1 letters, marks and symbols") lines = [ "ÀÁÂÃÄÅ ÇÈÉÊË ÌÍÎÏ ÑÒÓÔÕÖ ÙÚÛÜÝŸ", "àáâãäå çèéêë ìíîï ñòóôõö ùúûüýÿ", @@ -169,10 +182,66 @@ def accents(vf: str, out: Path) -> None: sheet.save(out) +CE_LINES = [ + "Zażółć gęślą jaźń · Příliš žluťoučký kůň", + "Árvíztűrő tükörfúrógép · Șirul țărilor", + "Pijamalı hasta yağız şoföre · Đurđa čuči", + "Glāžšķūņa rūķīši ĢĶĻŅ · Įlinkusi ąžuolų", + "Ċirku ħabib Ġużeppi · Ĉiuĵaŭde eĥoŝanĝo", +] + + +def latin_ext(vf: str, out: Path) -> None: + sheet = Sheet("machine-made Heddle 0.2: Latin Extended-A (U+0100 to U+017F), S and T with comma below, Central European text") + chars = "".join(chr(u) for u in range(0x100, 0x180)) + "ȘșȚț" + for wght, wdth in ((400, 100), (900, 85)): + sheet.text(f"wght {wght}, wdth {wdth}") + font = font_at(vf, 56, wght, wdth) + for i in range(0, len(chars), 33): + sheet.line(chars[i:i + 33], font, 56) + for s_ in CE_LINES: + sheet.line(s_, font_at(vf, 48, wght, wdth), 48) + sheet.gap() + sheet.save(out) + + +def symbols(vf: str, out: Path) -> None: + sheet = Sheet("machine-made Heddle 0.2: new symbols, fractions (frac off, then on), redrawn germandbls eth at copyright registered percent") + for wght, wdth in ((400, 100), (900, 100), (900, 70)): + sheet.text(f"wght {wght}, wdth {wdth}") + font = font_at(vf, 72, wght, wdth) + sheet.line("¢5 £20 ¥300 §12 ¶ ±3 2×4 8÷2 −1", font, 72) + sheet.line("½ ¼ ¾ 1/2 3/4 15/16 2027/10", font, 72, "frac off", features=["-frac"]) + sheet.line("½ ¼ ¾ 1/2 3/4 15/16 2027/10", font, 72, "frac on", features=["frac"]) + sheet.line("ß ð @ © ® % Straße ðe a@b.ch", font, 72) + sheet.gap() + sheet.save(out) + + +def before_after(old: str, vf: str, out_dir: Path) -> None: + sets = [ + ("accents", "marks: Heddle 0.1 (upper line) and 0.2 (lower line)", + [(900, 100), (900, 70), (400, 100)], "ïÏíÎ ÄÖÜ äöü ñÑ õÕ åÅ ÉÈ naïve"), + ("counters", "hooks and small rings at Black: 0.1 (upper) and 0.2 (lower)", + [(900, 100), (900, 70)], "afjst ß ð @ © ® % jet fast"), + ("drift", "glyphs that drifted most near wdth 85: 0.1 (upper) and 0.2 (lower)", + [(900, 85), (800, 85), (900, 78)], "J z s ä õ – a Jazz"), + ] + for key, title, locs, text in sets: + sheet = Sheet(f"machine-made Heddle: before/after, {title}") + for wght, wdth in locs: + sheet.text(f"wght {wght}, wdth {wdth}") + sheet.line(text, font_at(old, 80, wght, wdth), 80, "0.1") + sheet.line(text, font_at(vf, 80, wght, wdth), 80, "0.2") + sheet.gap() + sheet.save(out_dir / f"heddle-before-after-{key}.png") + + def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description="Render the Heddle proof sheets.") ap.add_argument("--font", default="../fonts/heddle/variable/MachineMadeHeddle[wdth,wght].ttf") ap.add_argument("--out-dir", default="../proofs/heddle") + ap.add_argument("--before", help="An older variable font for the before/after sheets.") args = ap.parse_args(argv) out = Path(args.out_dir) out.mkdir(parents=True, exist_ok=True) @@ -182,6 +251,10 @@ def main(argv: list[str] | None = None) -> int: designspace(args.font, out / "heddle-designspace.png") kerning(args.font, out / "heddle-kerning.png") accents(args.font, out / "heddle-accents.png") + latin_ext(args.font, out / "heddle-latin-ext.png") + symbols(args.font, out / "heddle-symbols.png") + if args.before: + before_after(args.before, args.font, out) return 0 diff --git a/heddle/tools/readability.py b/heddle/tools/readability.py new file mode 100644 index 0000000..e398bd3 --- /dev/null +++ b/heddle/tools/readability.py @@ -0,0 +1,164 @@ +"""OCR readability check for machine-made Heddle (a display face). + +Usage: + python tools/readability.py [--out ../proofs/heddle/readability.json] + [--baseline DIR] [--gate] + +Heddle is a display face, not a text face, so this is not the text-face +gate of Weft and Warp. It stops regressions: the tool sets a headline +paragraph in Heddle Regular, Bold and Black at 24, 32 and 48 px +(FreeType through Pillow, default features), runs Tesseract OCR +(English, page mode 6) and computes the character error rate (CER): the +Levenshtein distance between OCR text and true text, divided by the +length of the true text, after normalising whitespace. DejaVu Sans +(Book, Bold) is the reference. + +--baseline DIR: also measure the static TTFs in DIR (for example the + Heddle 0.1 fonts from git) and record them as "before". +--gate: exit 1 if, for any style, the mean CER is more than MARGIN above + the baseline (when given) or above the reference plus REF_MARGIN. + +Apache-2.0. +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +HERE = Path(__file__).resolve().parent +FONTS = HERE.parents[1] / "fonts" / "heddle" / "ttf" + +PARAGRAPH = ( + "Swiss posters of 1958 set big words in tight grotesks. Heddle builds " + "every letter from four parts: rect, ring, slant and vee. Quick zebras " + "jump over the lazy dog; 27 boxes of 64 jugs weigh 3,590 kg." +) +COLUMNS = 40 +SIZES = (24, 32, 48) +STYLES = {"Regular": "MachineMadeHeddle-Regular.ttf", "Bold": "MachineMadeHeddle-Bold.ttf", + "Black": "MachineMadeHeddle-Black.ttf"} +REFERENCE = { + "Regular": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "Bold": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "Black": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", +} +# A new version may be at most 1 point worse than the baseline. +MARGIN = 0.01 +# Without a baseline: at most 5 points worse than the reference. +REF_MARGIN = 0.05 + + +def wrap(text: str, cols: int) -> list[str]: + lines, line = [], "" + for word in text.split(): + if line and len(line) + 1 + len(word) > cols: + lines.append(line) + line = word + else: + line = f"{line} {word}" if line else word + if line: + lines.append(line) + return lines + + +def render(font_path: str, lines: list[str], size: int) -> Image.Image: + font = ImageFont.truetype(font_path, size) + step = round(size * 1.35) + width = max(int(font.getlength(line)) for line in lines) + 2 * size + img = Image.new("L", (width, step * len(lines) + 2 * size), 255) + draw = ImageDraw.Draw(img) + for i, line in enumerate(lines): + draw.text((size, size + i * step), line, font=font, fill=0) + return img + + +def ocr(img: Image.Image) -> str: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "in.png" + img.save(path) + cmd = ["tesseract", str(path), "stdout", "-l", "eng", "--psm", "6"] + return subprocess.run(cmd, capture_output=True, text=True, check=True).stdout + + +def norm(s: str) -> str: + return " ".join(s.replace("’", "'").split()) + + +def levenshtein(a: str, b: str) -> int: + prev = list(range(len(b) + 1)) + for i in range(1, len(a) + 1): + cur = [i] + [0] * len(b) + for j in range(1, len(b) + 1): + cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] != b[j - 1])) + prev = cur + return prev[-1] + + +def cer(truth: str, read: str) -> float: + t, r = norm(truth), norm(read) + return levenshtein(t, r) / max(1, len(t)) + + +def measure(font_path: str) -> dict: + lines = wrap(PARAGRAPH, COLUMNS) + by = {str(s): round(cer(PARAGRAPH, ocr(render(font_path, lines, s))), 4) for s in SIZES} + return {"cer_by_px": by, "cer_mean": round(sum(by.values()) / len(by), 4)} + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="OCR readability check for Heddle.") + ap.add_argument("--fonts", default=str(FONTS)) + ap.add_argument("--baseline", help="Folder with the static TTFs of an older version.") + ap.add_argument("--baseline-label", default="Heddle 0.1") + ap.add_argument("--out", default=str(HERE.parents[1] / "proofs" / "heddle" / "readability.json")) + ap.add_argument("--gate", action="store_true") + args = ap.parse_args(argv) + if shutil.which("tesseract") is None: + print("Error: tesseract is not installed. Install tesseract-ocr.", file=sys.stderr) + return 2 + version = subprocess.run(["tesseract", "--version"], capture_output=True, text=True).stdout.split("\n")[0] + now = {k: measure(str(Path(args.fonts) / v)) for k, v in STYLES.items()} + ref = {k: measure(v) for k, v in REFERENCE.items()} + before = {k: measure(str(Path(args.baseline) / v)) for k, v in STYLES.items()} if args.baseline else None + gate = {"margin_over_baseline": MARGIN, "margin_over_reference": REF_MARGIN, "checks": [], "pass": True} + for k in STYLES: + if before: + limit = before[k]["cer_mean"] + MARGIN + against = "baseline" + else: + limit = ref[k]["cer_mean"] + REF_MARGIN + against = "reference" + ok = now[k]["cer_mean"] <= limit + 1e-9 + gate["checks"].append({"style": k, "cer": now[k]["cer_mean"], "against": against, + "limit": round(limit, 4), "pass": ok}) + gate["pass"] &= ok + report = { + "about": "OCR character error rate (CER), lower is better. Heddle is a display face: " + "this stops regressions; it is not a text-face gate. See heddle/tools/readability.py.", + "tesseract": version, + "paragraph": PARAGRAPH, + "sizes_px": list(SIZES), + "heddle": {"font": "machine-made Heddle 0.2 (static TTF)", **now}, + "reference": {"font": "DejaVu Sans (Book; Bold for Bold and Black)", **ref}, + "gate": gate, + } + if before: + report["before"] = {"font": args.baseline_label, **before} + Path(args.out).write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + for k in STYLES: + b = f", before {before[k]['cer_mean']}" if before else "" + print(f"{k}: CER {now[k]['cer_mean']}{b}, reference {ref[k]['cer_mean']}") + print("gate:", "pass" if gate["pass"] else "FAIL") + return 0 if (gate["pass"] or not args.gate) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/heddle/tools/sketch_compare.py b/heddle/tools/sketch_compare.py index 6d80c41..957cd48 100644 --- a/heddle/tools/sketch_compare.py +++ b/heddle/tools/sketch_compare.py @@ -169,12 +169,12 @@ def main(argv=None) -> int: width = 1800 blocks = [block(PANGRAM, w, d, 60, width, f"wght {w}, wdth {d}" + (" (between masters)" if (w, d) in BETWEEN else "")) for w, d in CORNERS + BETWEEN] - s = render(args.font, "Heddle 0.1: sketch engine and font, pangram", key, blocks, + s = render(args.font, "Heddle 0.2: sketch engine and font, pangram", key, blocks, args.out / "heddle-sketch-vs-font-pangram.png", width) report["pangram"] = {f"{b['wght']},{b['wdth']}": v for b, v in zip(blocks, s)} blocks = [block(CHARSET, w, d, 56, width, f"wght {w}, wdth {d}") for w, d in CORNERS] - s = render(args.font, "Heddle 0.1: sketch engine and font, the 70 sketch characters", key, blocks, + s = render(args.font, "Heddle 0.2: sketch engine and font, the 70 sketch characters", key, blocks, args.out / "heddle-sketch-vs-font-charset.png", width) report["charset"] = {f"{b['wght']},{b['wdth']}": v for b, v in zip(blocks, s)} diff --git a/proofs/heddle/drift.json b/proofs/heddle/drift.json index 0503e5f..cef8e59 100644 --- a/proofs/heddle/drift.json +++ b/proofs/heddle/drift.json @@ -85,27 +85,31 @@ [ 850, 70 + ], + [ + 800, + 78 + ], + [ + 900, + 78 + ], + [ + 900, + 92 + ], + [ + 650, + 92 ] ], - "max_shape": 0.1111, - "max_shape_sketch_glyphs": 0.1111, - "max_advance": 38.8, - "max_advance_sketch_glyphs": 35.3, + "max_shape": 0.0873, + "max_shape_sketch_glyphs": 0.0873, + "max_advance": 30.6, + "max_advance_sketch_glyphs": 30.6, "median_shape": 0.0 }, "worst_shape": { - "J": { - "shape": 0.1111, - "shape_at": [ - 900, - 85 - ], - "advance": 22.7, - "advance_at": [ - 900, - 85 - ] - }, "hyphen": { "shape": 0.0873, "shape_at": [ @@ -118,124 +122,136 @@ 85 ] }, - "otilde": { - "shape": 0.066, + "J": { + "shape": 0.0675, "shape_at": [ - 800, - 85 + 900, + 78 ], - "advance": 38.8, + "advance": 17.3, "advance_at": [ - 800, - 85 + 900, + 78 ] }, - "z": { - "shape": 0.0646, + "Jcircumflex": { + "shape": 0.0592, "shape_at": [ 900, - 85 + 78 ], - "advance": 35.3, + "advance": 17.3, "advance_at": [ 900, - 85 + 78 ] }, - "endash": { - "shape": 0.0546, + "uni0163": { + "shape": 0.0592, "shape_at": [ - 900, + 550, 85 ], - "advance": 29.7, + "advance": 26.3, "advance_at": [ 900, - 85 + 120 ] }, - "s": { - "shape": 0.0544, + "uni021B": { + "shape": 0.0566, "shape_at": [ - 900, + 550, 85 ], - "advance": 29.7, + "advance": 26.3, "advance_at": [ 900, - 85 + 120 ] }, - "adieresis": { - "shape": 0.0519, + "uogonek": { + "shape": 0.0406, "shape_at": [ - 900, - 85 + 850, + 70 ], - "advance": 27.2, + "advance": 14.6, "advance_at": [ 900, - 85 + 78 ] }, - "ydieresis": { - "shape": 0.0502, + "aogonek": { + "shape": 0.0391, "shape_at": [ 900, - 85 + 78 ], - "advance": 27.2, + "advance": 17.3, "advance_at": [ 900, - 85 + 78 ] }, - "atilde": { - "shape": 0.0495, + "u": { + "shape": 0.0387, "shape_at": [ - 700, - 85 + 850, + 70 ], - "advance": 35.2, + "advance": 14.6, "advance_at": [ - 700, - 85 + 900, + 78 ] }, - "ntilde": { - "shape": 0.049, + "IJ": { + "shape": 0.0375, "shape_at": [ - 700, - 85 + 900, + 78 ], - "advance": 28.8, + "advance": 17.6, "advance_at": [ - 800, - 85 + 900, + 78 ] }, - "a": { - "shape": 0.0424, + "dcaron": { + "shape": 0.037, "shape_at": [ - 900, + 850, + 70 + ], + "advance": 13.3, + "advance_at": [ + 850, + 70 + ] + }, + "t": { + "shape": 0.0359, + "shape_at": [ + 550, 85 ], - "advance": 22.7, + "advance": 26.3, "advance_at": [ 900, - 85 + 120 ] }, - "udieresis": { - "shape": 0.0387, + "slash": { + "shape": 0.0352, "shape_at": [ 900, - 85 + 120 ], - "advance": 20.2, + "advance": 24.7, "advance_at": [ 900, - 85 + 120 ] } }, @@ -243,10 +259,10 @@ ".notdef": { "shape": 0.0, "shape_at": null, - "advance": 0.3, + "advance": 0.5, "advance_at": [ - 800, - 100 + 900, + 85 ] }, "exclam": { @@ -272,7 +288,7 @@ "shape_at": null, "advance": 0.5, "advance_at": [ - 100, + 700, 85 ] }, @@ -295,15 +311,12 @@ ] }, "ampersand": { - "shape": 0.0001, - "shape_at": [ - 550, - 85 - ], - "advance": 0.4, + "shape": 0.0, + "shape_at": null, + "advance": 0.5, "advance_at": [ - 850, - 70 + 700, + 85 ] }, "quotesingle": { @@ -320,8 +333,8 @@ "shape_at": null, "advance": 0.3, "advance_at": [ - 250, - 100 + 700, + 85 ] }, "parenright": { @@ -329,8 +342,8 @@ "shape_at": null, "advance": 0.3, "advance_at": [ - 250, - 100 + 700, + 85 ] }, "asterisk": { @@ -338,8 +351,8 @@ "shape_at": null, "advance": 0.4, "advance_at": [ - 300, - 70 + 900, + 85 ] }, "plus": { @@ -529,23 +542,23 @@ ] }, "question": { - "shape": 0.0046, + "shape": 0.0001, "shape_at": [ - 800, - 85 + 900, + 78 ], - "advance": 0.3, + "advance": 0.4, "advance_at": [ 100, - 120 + 85 ] }, "at": { "shape": 0.0, "shape_at": null, - "advance": 0.3, + "advance": 0.4, "advance_at": [ - 100, + 400, 85 ] }, @@ -554,8 +567,8 @@ "shape_at": null, "advance": 0.3, "advance_at": [ - 100, - 85 + 900, + 78 ] }, "B": { @@ -563,8 +576,8 @@ "shape_at": null, "advance": 0.3, "advance_at": [ - 100, - 85 + 900, + 78 ] }, "C": { @@ -572,8 +585,8 @@ "shape_at": null, "advance": 0.3, "advance_at": [ - 100, - 85 + 900, + 92 ] }, "D": { @@ -581,8 +594,8 @@ "shape_at": null, "advance": 0.3, "advance_at": [ - 100, - 85 + 900, + 78 ] }, "E": { @@ -598,15 +611,15 @@ ] }, "F": { - "shape": 0.0173, + "shape": 0.0152, "shape_at": [ 900, - 85 + 78 ], - "advance": 19.2, + "advance": 18.1, "advance_at": [ 900, - 85 + 78 ] }, "G": { @@ -623,8 +636,8 @@ "shape_at": null, "advance": 0.3, "advance_at": [ - 100, - 85 + 900, + 92 ] }, "I": { @@ -637,15 +650,15 @@ ] }, "J": { - "shape": 0.1111, + "shape": 0.0675, "shape_at": [ 900, - 85 + 78 ], - "advance": 22.7, + "advance": 17.3, "advance_at": [ 900, - 85 + 78 ] }, "K": { @@ -653,20 +666,20 @@ "shape_at": null, "advance": 0.3, "advance_at": [ - 100, - 85 + 900, + 92 ] }, "L": { - "shape": 0.0138, + "shape": 0.0107, "shape_at": [ 900, - 85 + 78 ], - "advance": 22.7, + "advance": 17.3, "advance_at": [ 900, - 85 + 78 ] }, "M": { @@ -681,9 +694,9 @@ "N": { "shape": 0.0, "shape_at": null, - "advance": 0.3, + "advance": 0.4, "advance_at": [ - 100, + 400, 85 ] }, @@ -692,8 +705,8 @@ "shape_at": null, "advance": 0.3, "advance_at": [ - 100, - 85 + 900, + 92 ] }, "P": { @@ -710,16 +723,16 @@ "shape_at": null, "advance": 0.3, "advance_at": [ - 100, - 85 + 900, + 92 ] }, "R": { "shape": 0.0, "shape_at": null, - "advance": 0.3, + "advance": 0.4, "advance_at": [ - 100, + 400, 85 ] }, @@ -728,8 +741,8 @@ "shape_at": null, "advance": 0.3, "advance_at": [ - 100, - 85 + 900, + 78 ] }, "T": { @@ -746,8 +759,8 @@ "shape_at": null, "advance": 0.3, "advance_at": [ - 100, - 85 + 900, + 92 ] }, "V": { @@ -755,20 +768,17 @@ "shape_at": null, "advance": 0.3, "advance_at": [ - 100, - 85 + 900, + 92 ] }, "W": { - "shape": 0.0002, - "shape_at": [ - 900, - 85 - ], + "shape": 0.0, + "shape_at": null, "advance": 0.3, "advance_at": [ - 100, - 85 + 900, + 92 ] }, "X": { @@ -776,8 +786,8 @@ "shape_at": null, "advance": 0.3, "advance_at": [ - 100, - 85 + 900, + 92 ] }, "Y": { @@ -785,16 +795,16 @@ "shape_at": null, "advance": 0.3, "advance_at": [ - 100, - 85 + 900, + 78 ] }, "Z": { "shape": 0.0, "shape_at": null, - "advance": 0.3, + "advance": 0.4, "advance_at": [ - 100, + 400, 85 ] }, @@ -803,8 +813,8 @@ "shape_at": null, "advance": 0.4, "advance_at": [ - 800, - 100 + 700, + 85 ] }, "backslash": { @@ -821,13 +831,16 @@ "shape_at": null, "advance": 0.4, "advance_at": [ - 800, - 100 + 700, + 85 ] }, "asciicircum": { - "shape": 0.0, - "shape_at": null, + "shape": 0.0002, + "shape_at": [ + 800, + 78 + ], "advance": 0.3, "advance_at": [ 100, @@ -837,35 +850,35 @@ "underscore": { "shape": 0.0, "shape_at": null, - "advance": 0.3, + "advance": 0.4, "advance_at": [ 100, - 120 + 85 ] }, "grave": { "shape": 0.0, "shape_at": null, - "advance": 0.3, + "advance": 0.5, "advance_at": [ - 400, + 100, 85 ] }, "a": { - "shape": 0.0424, + "shape": 0.0313, "shape_at": [ 900, - 85 + 78 ], - "advance": 22.7, + "advance": 17.3, "advance_at": [ 900, - 85 + 78 ] }, "b": { - "shape": 0.008, + "shape": 0.0064, "shape_at": [ 850, 70 @@ -877,19 +890,19 @@ ] }, "c": { - "shape": 0.0131, + "shape": 0.0076, "shape_at": [ 900, - 85 + 78 ], - "advance": 22.7, + "advance": 17.3, "advance_at": [ 900, - 85 + 78 ] }, "d": { - "shape": 0.0335, + "shape": 0.0344, "shape_at": [ 850, 70 @@ -901,19 +914,19 @@ ] }, "e": { - "shape": 0.0085, + "shape": 0.0081, "shape_at": [ - 900, - 85 + 850, + 70 ], - "advance": 15.7, + "advance": 14.6, "advance_at": [ 900, - 85 + 78 ] }, "f": { - "shape": 0.027, + "shape": 0.0305, "shape_at": [ 550, 85 @@ -925,7 +938,7 @@ ] }, "g": { - "shape": 0.0237, + "shape": 0.0241, "shape_at": [ 850, 70 @@ -937,15 +950,15 @@ ] }, "h": { - "shape": 0.0265, + "shape": 0.026, "shape_at": [ 850, 70 ], - "advance": 15.7, + "advance": 14.6, "advance_at": [ 900, - 85 + 78 ] }, "i": { @@ -967,15 +980,15 @@ ] }, "k": { - "shape": 0.0099, + "shape": 0.0054, "shape_at": [ 900, - 85 + 78 ], - "advance": 22.7, + "advance": 17.3, "advance_at": [ 900, - 85 + 78 ] }, "l": { @@ -997,19 +1010,19 @@ ] }, "n": { - "shape": 0.0317, + "shape": 0.031, "shape_at": [ 850, 70 ], - "advance": 15.7, + "advance": 14.6, "advance_at": [ 900, - 85 + 78 ] }, "o": { - "shape": 0.0103, + "shape": 0.0085, "shape_at": [ 850, 70 @@ -1021,7 +1034,7 @@ ] }, "p": { - "shape": 0.0079, + "shape": 0.0064, "shape_at": [ 850, 70 @@ -1033,7 +1046,7 @@ ] }, "q": { - "shape": 0.0336, + "shape": 0.0345, "shape_at": [ 850, 70 @@ -1057,22 +1070,22 @@ ] }, "s": { - "shape": 0.0544, + "shape": 0.0085, "shape_at": [ - 900, - 85 + 800, + 78 ], - "advance": 29.7, + "advance": 13.9, "advance_at": [ - 900, - 85 + 800, + 78 ] }, "t": { - "shape": 0.0175, + "shape": 0.0359, "shape_at": [ - 900, - 120 + 550, + 85 ], "advance": 26.3, "advance_at": [ @@ -1081,32 +1094,35 @@ ] }, "u": { - "shape": 0.0378, + "shape": 0.0387, "shape_at": [ 850, 70 ], - "advance": 15.7, + "advance": 14.6, "advance_at": [ 900, - 85 + 78 ] }, "v": { - "shape": 0.0351, + "shape": 0.0259, "shape_at": [ 900, - 85 + 78 ], - "advance": 22.7, + "advance": 17.3, "advance_at": [ 900, - 85 + 78 ] }, "w": { - "shape": 0.0, - "shape_at": null, + "shape": 0.0002, + "shape_at": [ + 900, + 78 + ], "advance": 0.3, "advance_at": [ 100, @@ -1114,39 +1130,39 @@ ] }, "x": { - "shape": 0.0255, + "shape": 0.0145, "shape_at": [ 900, - 85 + 78 ], - "advance": 22.7, + "advance": 17.3, "advance_at": [ 900, - 85 + 78 ] }, "y": { - "shape": 0.0266, + "shape": 0.0197, "shape_at": [ 900, - 85 + 78 ], - "advance": 22.7, + "advance": 17.3, "advance_at": [ 900, - 85 + 78 ] }, "z": { - "shape": 0.0646, + "shape": 0.0253, "shape_at": [ - 900, - 85 + 800, + 78 ], - "advance": 35.3, + "advance": 17.7, "advance_at": [ - 900, - 85 + 800, + 78 ] }, "braceleft": { @@ -1154,8 +1170,8 @@ "shape_at": null, "advance": 0.4, "advance_at": [ - 800, - 100 + 900, + 92 ] }, "bar": { @@ -1172,11 +1188,32 @@ "shape_at": null, "advance": 0.4, "advance_at": [ - 800, - 100 + 900, + 92 ] }, "asciitilde": { + "shape": 0.0063, + "shape_at": [ + 550, + 85 + ], + "advance": 0.3, + "advance_at": [ + 100, + 85 + ] + }, + "exclamdown": { + "shape": 0.0, + "shape_at": null, + "advance": 0.4, + "advance_at": [ + 400, + 85 + ] + }, + "cent": { "shape": 0.0, "shape_at": null, "advance": 0.3, @@ -1185,7 +1222,28 @@ 85 ] }, - "exclamdown": { + "sterling": { + "shape": 0.0002, + "shape_at": [ + 800, + 78 + ], + "advance": 0.3, + "advance_at": [ + 100, + 85 + ] + }, + "yen": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 100, + 85 + ] + }, + "section": { "shape": 0.0, "shape_at": null, "advance": 0.4, @@ -1230,6 +1288,24 @@ 85 ] }, + "plusminus": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 100, + 85 + ] + }, + "paragraph": { + "shape": 0.0, + "shape_at": null, + "advance": 0.5, + "advance_at": [ + 400, + 85 + ] + }, "periodcentered": { "shape": 0.0, "shape_at": null, @@ -1249,15 +1325,15 @@ ] }, "questiondown": { - "shape": 0.005, + "shape": 0.0001, "shape_at": [ - 800, - 85 + 900, + 78 ], - "advance": 0.3, + "advance": 0.4, "advance_at": [ 100, - 120 + 85 ] }, "AE": { @@ -1278,7 +1354,7 @@ 85 ] }, - "Oslash": { + "multiply": { "shape": 0.0, "shape_at": null, "advance": 0.3, @@ -1287,6 +1363,15 @@ 85 ] }, + "Oslash": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, "Thorn": { "shape": 0.0, "shape_at": null, @@ -1306,15 +1391,12 @@ ] }, "ae": { - "shape": 0.0022, - "shape_at": [ - 800, - 120 - ], + "shape": 0.0, + "shape_at": null, "advance": 0.3, "advance_at": [ - 100, - 85 + 900, + 78 ] }, "eth": { @@ -1326,6 +1408,15 @@ 70 ] }, + "divide": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 100, + 85 + ] + }, "oslash": { "shape": 0.0, "shape_at": null, @@ -1344,6 +1435,33 @@ 70 ] }, + "dcroat": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 100, + 85 + ] + }, + "Hbar": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 100, + 85 + ] + }, + "hbar": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 100, + 85 + ] + }, "dotlessi": { "shape": 0.0, "shape_at": null, @@ -1353,15 +1471,66 @@ 85 ] }, - "OE": { + "kgreenlandic": { + "shape": 0.0, + "shape_at": null, + "advance": 0.4, + "advance_at": [ + 100, + 85 + ] + }, + "Lslash": { "shape": 0.0, "shape_at": null, "advance": 0.3, + "advance_at": [ + 850, + 70 + ] + }, + "lslash": { + "shape": 0.0, + "shape_at": null, + "advance": 0.5, "advance_at": [ 100, 85 ] }, + "Eng": { + "shape": 0.0007, + "shape_at": [ + 800, + 100 + ], + "advance": 0.4, + "advance_at": [ + 400, + 85 + ] + }, + "eng": { + "shape": 0.0076, + "shape_at": [ + 550, + 85 + ], + "advance": 0.4, + "advance_at": [ + 400, + 85 + ] + }, + "OE": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, "oe": { "shape": 0.0, "shape_at": null, @@ -1371,18 +1540,60 @@ 85 ] }, - "endash": { - "shape": 0.0546, + "Tbar": { + "shape": 0.0, + "shape_at": null, + "advance": 0.4, + "advance_at": [ + 700, + 120 + ] + }, + "tbar": { + "shape": 0.0116, "shape_at": [ - 900, + 550, + 100 + ], + "advance": 0.5, + "advance_at": [ + 400, 85 + ] + }, + "longs": { + "shape": 0.0091, + "shape_at": [ + 250, + 120 ], - "advance": 29.7, + "advance": 0.3, "advance_at": [ - 900, + 700, + 85 + ] + }, + "dotlessj": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 250, 85 ] }, + "endash": { + "shape": 0.0198, + "shape_at": [ + 800, + 78 + ], + "advance": 13.9, + "advance_at": [ + 800, + 78 + ] + }, "emdash": { "shape": 0.0, "shape_at": null, @@ -1482,16 +1693,16 @@ 85 ] }, - "Euro": { + "fraction": { "shape": 0.0, "shape_at": null, "advance": 0.3, "advance_at": [ - 100, - 85 + 900, + 78 ] }, - "rupeeIndian": { + "Euro": { "shape": 0.0, "shape_at": null, "advance": 0.3, @@ -1500,7 +1711,7 @@ 85 ] }, - "Agrave": { + "rupeeIndian": { "shape": 0.0, "shape_at": null, "advance": 0.3, @@ -1509,7 +1720,7 @@ 85 ] }, - "Aacute": { + "minus": { "shape": 0.0, "shape_at": null, "advance": 0.3, @@ -1518,103 +1729,52 @@ 85 ] }, - "Acircumflex": { + "eight.dnom": { "shape": 0.0, "shape_at": null, - "advance": 0.3, + "advance": 0.4, "advance_at": [ - 100, + 400, 85 ] }, - "Atilde": { - "shape": 0.003, - "shape_at": [ - 900, - 85 - ], - "advance": 19.2, + "eight.numr": { + "shape": 0.0, + "shape_at": null, + "advance": 0.4, "advance_at": [ - 900, + 400, 85 ] }, - "Adieresis": { + "five.dnom": { "shape": 0.0, "shape_at": null, - "advance": 0.3, + "advance": 0.4, "advance_at": [ - 100, + 400, 85 ] }, - "Aring": { + "five.numr": { "shape": 0.0, "shape_at": null, - "advance": 0.3, + "advance": 0.4, "advance_at": [ - 100, + 400, 85 ] }, - "Ccedilla": { + "four.dnom": { "shape": 0.0, "shape_at": null, - "advance": 0.3, + "advance": 0.4, "advance_at": [ - 100, + 400, 85 ] }, - "Egrave": { - "shape": 0.0139, - "shape_at": [ - 850, - 70 - ], - "advance": 13.3, - "advance_at": [ - 850, - 70 - ] - }, - "Eacute": { - "shape": 0.0139, - "shape_at": [ - 850, - 70 - ], - "advance": 13.3, - "advance_at": [ - 850, - 70 - ] - }, - "Ecircumflex": { - "shape": 0.0131, - "shape_at": [ - 850, - 70 - ], - "advance": 13.3, - "advance_at": [ - 850, - 70 - ] - }, - "Edieresis": { - "shape": 0.0104, - "shape_at": [ - 850, - 70 - ], - "advance": 14.6, - "advance_at": [ - 850, - 70 - ] - }, - "Igrave": { + "four.numr": { "shape": 0.0, "shape_at": null, "advance": 0.4, @@ -1623,7 +1783,7 @@ 85 ] }, - "Iacute": { + "nine.dnom": { "shape": 0.0, "shape_at": null, "advance": 0.4, @@ -1632,16 +1792,16 @@ 85 ] }, - "Icircumflex": { + "nine.numr": { "shape": 0.0, "shape_at": null, - "advance": 0.5, + "advance": 0.4, "advance_at": [ - 900, + 400, 85 ] }, - "Idieresis": { + "one.dnom": { "shape": 0.0, "shape_at": null, "advance": 0.4, @@ -1650,428 +1810,2027 @@ 85 ] }, - "Ntilde": { - "shape": 0.0214, - "shape_at": [ - 900, - 85 - ], - "advance": 22.7, + "one.numr": { + "shape": 0.0, + "shape_at": null, + "advance": 0.4, "advance_at": [ - 900, + 400, 85 ] }, - "Ograve": { + "seven.dnom": { "shape": 0.0, "shape_at": null, - "advance": 0.3, + "advance": 0.4, "advance_at": [ - 100, + 400, 85 ] }, - "Oacute": { + "seven.numr": { "shape": 0.0, "shape_at": null, - "advance": 0.3, + "advance": 0.4, "advance_at": [ - 100, + 400, 85 ] }, - "Ocircumflex": { + "six.dnom": { "shape": 0.0, "shape_at": null, - "advance": 0.3, + "advance": 0.4, "advance_at": [ - 100, + 400, 85 ] }, - "Otilde": { + "six.numr": { "shape": 0.0, "shape_at": null, - "advance": 0.3, + "advance": 0.4, "advance_at": [ - 100, + 400, 85 ] }, - "Odieresis": { + "three.dnom": { "shape": 0.0, "shape_at": null, - "advance": 0.3, + "advance": 0.4, "advance_at": [ - 100, + 400, 85 ] }, - "Ugrave": { + "three.numr": { "shape": 0.0, "shape_at": null, - "advance": 0.3, + "advance": 0.4, "advance_at": [ - 100, + 400, 85 ] }, - "Uacute": { + "two.dnom": { "shape": 0.0, "shape_at": null, - "advance": 0.3, + "advance": 0.4, "advance_at": [ - 100, + 400, 85 ] }, - "Ucircumflex": { + "two.numr": { "shape": 0.0, "shape_at": null, - "advance": 0.3, + "advance": 0.4, "advance_at": [ - 100, + 400, 85 ] }, - "Udieresis": { + "zero.dnom": { "shape": 0.0, "shape_at": null, - "advance": 0.3, + "advance": 0.4, "advance_at": [ - 100, + 400, 85 ] }, - "Yacute": { + "zero.numr": { "shape": 0.0, "shape_at": null, - "advance": 0.3, + "advance": 0.4, "advance_at": [ - 100, + 400, 85 ] }, - "agrave": { - "shape": 0.0357, - "shape_at": [ - 900, - 85 - ], - "advance": 22.7, + "dieresis": { + "shape": 0.0, + "shape_at": null, + "advance": 0.5, "advance_at": [ - 900, + 700, 85 ] }, - "aacute": { - "shape": 0.0357, - "shape_at": [ - 900, - 85 - ], - "advance": 22.7, + "macron": { + "shape": 0.0, + "shape_at": null, + "advance": 0.4, "advance_at": [ - 900, + 700, 85 ] }, - "acircumflex": { - "shape": 0.0324, - "shape_at": [ - 900, - 85 - ], - "advance": 22.7, + "acute": { + "shape": 0.0, + "shape_at": null, + "advance": 0.5, "advance_at": [ - 900, + 100, 85 ] }, - "atilde": { - "shape": 0.0495, - "shape_at": [ - 700, - 85 - ], - "advance": 35.2, + "cedilla": { + "shape": 0.0, + "shape_at": null, + "advance": 0.5, "advance_at": [ 700, 85 ] }, - "adieresis": { - "shape": 0.0519, - "shape_at": [ - 900, - 85 - ], - "advance": 27.2, + "onequarter": { + "shape": 0.0, + "shape_at": null, + "advance": 0.5, "advance_at": [ - 900, + 700, 85 ] }, - "aring": { - "shape": 0.0325, - "shape_at": [ - 900, - 85 - ], - "advance": 22.7, + "onehalf": { + "shape": 0.0, + "shape_at": null, + "advance": 0.5, "advance_at": [ - 900, + 700, 85 ] }, - "ccedilla": { - "shape": 0.018, - "shape_at": [ - 900, - 85 - ], - "advance": 22.7, + "threequarters": { + "shape": 0.0, + "shape_at": null, + "advance": 0.5, "advance_at": [ - 900, + 700, 85 ] }, - "egrave": { - "shape": 0.0072, - "shape_at": [ - 900, - 85 - ], - "advance": 15.7, + "Agrave": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, "advance_at": [ 900, - 85 + 78 ] }, - "eacute": { - "shape": 0.0072, - "shape_at": [ - 900, - 85 - ], - "advance": 15.7, + "Aacute": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, "advance_at": [ 900, - 85 + 78 ] }, - "ecircumflex": { - "shape": 0.0065, - "shape_at": [ - 900, - 85 - ], - "advance": 15.7, + "Acircumflex": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, "advance_at": [ 900, - 85 + 78 ] }, - "edieresis": { - "shape": 0.0165, - "shape_at": [ - 900, - 85 - ], - "advance": 20.2, + "Atilde": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, "advance_at": [ 900, - 85 + 78 ] }, - "igrave": { + "Adieresis": { "shape": 0.0, "shape_at": null, - "advance": 0.5, + "advance": 0.3, "advance_at": [ 900, - 85 + 78 ] }, - "iacute": { + "Aring": { "shape": 0.0, "shape_at": null, - "advance": 0.5, + "advance": 0.3, "advance_at": [ 900, - 85 + 78 ] }, - "icircumflex": { + "Ccedilla": { "shape": 0.0, "shape_at": null, "advance": 0.3, "advance_at": [ - 700, - 85 + 900, + 92 ] }, - "idieresis": { - "shape": 0.0, - "shape_at": null, + "Egrave": { + "shape": 0.0145, + "shape_at": [ + 850, + 70 + ], + "advance": 13.3, + "advance_at": [ + 850, + 70 + ] + }, + "Eacute": { + "shape": 0.0145, + "shape_at": [ + 850, + 70 + ], + "advance": 13.3, + "advance_at": [ + 850, + 70 + ] + }, + "Ecircumflex": { + "shape": 0.0141, + "shape_at": [ + 850, + 70 + ], + "advance": 13.3, + "advance_at": [ + 850, + 70 + ] + }, + "Edieresis": { + "shape": 0.0139, + "shape_at": [ + 850, + 70 + ], + "advance": 13.3, + "advance_at": [ + 850, + 70 + ] + }, + "Igrave": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 250, + 85 + ] + }, + "Iacute": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 250, + 85 + ] + }, + "Icircumflex": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 250, + 85 + ] + }, + "Idieresis": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 250, + 85 + ] + }, + "Ntilde": { + "shape": 0.0, + "shape_at": null, + "advance": 0.4, + "advance_at": [ + 400, + 85 + ] + }, + "Ograve": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "Oacute": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "Ocircumflex": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "Otilde": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "Odieresis": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "Ugrave": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "Uacute": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "Ucircumflex": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "Udieresis": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "Yacute": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 78 + ] + }, + "agrave": { + "shape": 0.0276, + "shape_at": [ + 900, + 78 + ], + "advance": 17.3, + "advance_at": [ + 900, + 78 + ] + }, + "aacute": { + "shape": 0.0276, + "shape_at": [ + 900, + 78 + ], + "advance": 17.3, + "advance_at": [ + 900, + 78 + ] + }, + "acircumflex": { + "shape": 0.026, + "shape_at": [ + 900, + 78 + ], + "advance": 17.3, + "advance_at": [ + 900, + 78 + ] + }, + "atilde": { + "shape": 0.0196, + "shape_at": [ + 800, + 78 + ], + "advance": 18.8, + "advance_at": [ + 800, + 78 + ] + }, + "adieresis": { + "shape": 0.0269, + "shape_at": [ + 900, + 78 + ], + "advance": 17.3, + "advance_at": [ + 900, + 78 + ] + }, + "aring": { + "shape": 0.026, + "shape_at": [ + 900, + 78 + ], + "advance": 17.3, + "advance_at": [ + 900, + 78 + ] + }, + "ccedilla": { + "shape": 0.007, + "shape_at": [ + 900, + 78 + ], + "advance": 17.3, + "advance_at": [ + 900, + 78 + ] + }, + "egrave": { + "shape": 0.0071, + "shape_at": [ + 850, + 70 + ], + "advance": 14.6, + "advance_at": [ + 900, + 78 + ] + }, + "eacute": { + "shape": 0.0071, + "shape_at": [ + 850, + 70 + ], + "advance": 14.6, + "advance_at": [ + 900, + 78 + ] + }, + "ecircumflex": { + "shape": 0.0066, + "shape_at": [ + 850, + 70 + ], + "advance": 14.6, + "advance_at": [ + 900, + 78 + ] + }, + "edieresis": { + "shape": 0.0069, + "shape_at": [ + 850, + 70 + ], + "advance": 14.6, + "advance_at": [ + 900, + 78 + ] + }, + "igrave": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 250, + 85 + ] + }, + "iacute": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 250, + 85 + ] + }, + "icircumflex": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 250, + 85 + ] + }, + "idieresis": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 250, + 85 + ] + }, + "ntilde": { + "shape": 0.017, + "shape_at": [ + 900, + 78 + ], + "advance": 14.6, + "advance_at": [ + 800, + 78 + ] + }, + "ograve": { + "shape": 0.0074, + "shape_at": [ + 850, + 70 + ], + "advance": 13.3, + "advance_at": [ + 850, + 70 + ] + }, + "oacute": { + "shape": 0.0074, + "shape_at": [ + 850, + 70 + ], + "advance": 13.3, + "advance_at": [ + 850, + 70 + ] + }, + "ocircumflex": { + "shape": 0.007, + "shape_at": [ + 850, + 70 + ], + "advance": 13.3, + "advance_at": [ + 850, + 70 + ] + }, + "otilde": { + "shape": 0.0157, + "shape_at": [ + 900, + 78 + ], + "advance": 19.1, + "advance_at": [ + 900, + 78 + ] + }, + "odieresis": { + "shape": 0.0073, + "shape_at": [ + 850, + 70 + ], + "advance": 13.3, + "advance_at": [ + 850, + 70 + ] + }, + "ugrave": { + "shape": 0.0344, + "shape_at": [ + 850, + 70 + ], + "advance": 14.6, + "advance_at": [ + 900, + 78 + ] + }, + "uacute": { + "shape": 0.0344, + "shape_at": [ + 850, + 70 + ], + "advance": 14.6, + "advance_at": [ + 900, + 78 + ] + }, + "ucircumflex": { + "shape": 0.0325, + "shape_at": [ + 850, + 70 + ], + "advance": 14.6, + "advance_at": [ + 900, + 78 + ] + }, + "udieresis": { + "shape": 0.0338, + "shape_at": [ + 850, + 70 + ], + "advance": 14.6, + "advance_at": [ + 900, + 78 + ] + }, + "yacute": { + "shape": 0.0176, + "shape_at": [ + 900, + 78 + ], + "advance": 17.3, + "advance_at": [ + 900, + 78 + ] + }, + "ydieresis": { + "shape": 0.025, + "shape_at": [ + 900, + 78 + ], + "advance": 17.3, + "advance_at": [ + 900, + 78 + ] + }, + "Amacron": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 78 + ] + }, + "amacron": { + "shape": 0.0281, + "shape_at": [ + 900, + 78 + ], + "advance": 17.3, + "advance_at": [ + 900, + 78 + ] + }, + "Abreve": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 78 + ] + }, + "abreve": { + "shape": 0.0278, + "shape_at": [ + 900, + 78 + ], + "advance": 17.3, + "advance_at": [ + 900, + 78 + ] + }, + "Aogonek": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 78 + ] + }, + "aogonek": { + "shape": 0.0391, + "shape_at": [ + 900, + 78 + ], + "advance": 17.3, + "advance_at": [ + 900, + 78 + ] + }, + "Cacute": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "cacute": { + "shape": 0.0065, + "shape_at": [ + 900, + 78 + ], + "advance": 17.3, + "advance_at": [ + 900, + 78 + ] + }, + "Ccircumflex": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "ccircumflex": { + "shape": 0.006, + "shape_at": [ + 900, + 78 + ], + "advance": 17.3, + "advance_at": [ + 900, + 78 + ] + }, + "Cdotaccent": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "cdotaccent": { + "shape": 0.0069, + "shape_at": [ + 900, + 78 + ], + "advance": 17.3, + "advance_at": [ + 900, + 78 + ] + }, + "Ccaron": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "ccaron": { + "shape": 0.006, + "shape_at": [ + 900, + 78 + ], + "advance": 17.3, + "advance_at": [ + 900, + 78 + ] + }, + "Dcaron": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 78 + ] + }, + "dcaron": { + "shape": 0.037, + "shape_at": [ + 850, + 70 + ], + "advance": 13.3, + "advance_at": [ + 850, + 70 + ] + }, + "Emacron": { + "shape": 0.0146, + "shape_at": [ + 850, + 70 + ], + "advance": 13.3, + "advance_at": [ + 850, + 70 + ] + }, + "emacron": { + "shape": 0.0072, + "shape_at": [ + 850, + 70 + ], + "advance": 14.6, + "advance_at": [ + 900, + 78 + ] + }, + "Ebreve": { + "shape": 0.0149, + "shape_at": [ + 850, + 70 + ], + "advance": 13.3, + "advance_at": [ + 850, + 70 + ] + }, + "ebreve": { + "shape": 0.0071, + "shape_at": [ + 850, + 70 + ], + "advance": 14.6, + "advance_at": [ + 900, + 78 + ] + }, + "Edotaccent": { + "shape": 0.0147, + "shape_at": [ + 850, + 70 + ], + "advance": 13.3, + "advance_at": [ + 850, + 70 + ] + }, + "edotaccent": { + "shape": 0.0074, + "shape_at": [ + 850, + 70 + ], + "advance": 14.6, + "advance_at": [ + 900, + 78 + ] + }, + "Eogonek": { + "shape": 0.0192, + "shape_at": [ + 850, + 70 + ], + "advance": 13.3, + "advance_at": [ + 850, + 70 + ] + }, + "eogonek": { + "shape": 0.009, + "shape_at": [ + 900, + 78 + ], + "advance": 14.6, + "advance_at": [ + 900, + 78 + ] + }, + "Ecaron": { + "shape": 0.0141, + "shape_at": [ + 850, + 70 + ], + "advance": 13.3, + "advance_at": [ + 850, + 70 + ] + }, + "ecaron": { + "shape": 0.0066, + "shape_at": [ + 850, + 70 + ], + "advance": 14.6, + "advance_at": [ + 900, + 78 + ] + }, + "Gcircumflex": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 100, + 85 + ] + }, + "gcircumflex": { + "shape": 0.0211, + "shape_at": [ + 850, + 70 + ], + "advance": 13.3, + "advance_at": [ + 850, + 70 + ] + }, + "Gbreve": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 100, + 85 + ] + }, + "gbreve": { + "shape": 0.0221, + "shape_at": [ + 850, + 70 + ], + "advance": 13.3, + "advance_at": [ + 850, + 70 + ] + }, + "Gdotaccent": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 100, + 85 + ] + }, + "gdotaccent": { + "shape": 0.0228, + "shape_at": [ + 850, + 70 + ], + "advance": 13.3, + "advance_at": [ + 850, + 70 + ] + }, + "uni0122": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 100, + 85 + ] + }, + "uni0123": { + "shape": 0.0223, + "shape_at": [ + 850, + 70 + ], + "advance": 13.3, + "advance_at": [ + 850, + 70 + ] + }, + "Hcircumflex": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "hcircumflex": { + "shape": 0.0236, + "shape_at": [ + 850, + 70 + ], + "advance": 14.6, + "advance_at": [ + 900, + 78 + ] + }, + "Itilde": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 250, + 85 + ] + }, + "itilde": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 250, + 85 + ] + }, + "Imacron": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 250, + 85 + ] + }, + "imacron": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 250, + 85 + ] + }, + "Ibreve": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 250, + 85 + ] + }, + "ibreve": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 250, + 85 + ] + }, + "Iogonek": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 250, + 85 + ] + }, + "iogonek": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 250, + 85 + ] + }, + "Idotaccent": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 250, + 85 + ] + }, + "IJ": { + "shape": 0.0375, + "shape_at": [ + 900, + 78 + ], + "advance": 17.6, + "advance_at": [ + 900, + 78 + ] + }, + "ij": { + "shape": 0.0, + "shape_at": null, + "advance": 0.4, + "advance_at": [ + 250, + 85 + ] + }, + "Jcircumflex": { + "shape": 0.0592, + "shape_at": [ + 900, + 78 + ], + "advance": 17.3, + "advance_at": [ + 900, + 78 + ] + }, + "jcircumflex": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 250, + 85 + ] + }, + "uni0136": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "uni0137": { + "shape": 0.0068, + "shape_at": [ + 900, + 78 + ], + "advance": 17.3, + "advance_at": [ + 900, + 78 + ] + }, + "Lacute": { + "shape": 0.0101, + "shape_at": [ + 900, + 78 + ], + "advance": 17.3, + "advance_at": [ + 900, + 78 + ] + }, + "lacute": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 250, + 85 + ] + }, + "uni013B": { + "shape": 0.0119, + "shape_at": [ + 900, + 78 + ], + "advance": 17.3, + "advance_at": [ + 900, + 78 + ] + }, + "uni013C": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 250, + 85 + ] + }, + "Lcaron": { + "shape": 0.0096, + "shape_at": [ + 900, + 78 + ], + "advance": 17.3, + "advance_at": [ + 900, + 78 + ] + }, + "lcaron": { + "shape": 0.0, + "shape_at": null, + "advance": 0.4, + "advance_at": [ + 900, + 85 + ] + }, + "Ldot": { + "shape": 0.0087, + "shape_at": [ + 900, + 78 + ], + "advance": 17.3, + "advance_at": [ + 900, + 78 + ] + }, + "ldot": { + "shape": 0.0, + "shape_at": null, + "advance": 0.4, + "advance_at": [ + 900, + 85 + ] + }, + "Nacute": { + "shape": 0.0, + "shape_at": null, + "advance": 0.4, + "advance_at": [ + 400, + 85 + ] + }, + "nacute": { + "shape": 0.0276, + "shape_at": [ + 850, + 70 + ], + "advance": 14.6, + "advance_at": [ + 900, + 78 + ] + }, + "uni0145": { + "shape": 0.0, + "shape_at": null, + "advance": 0.4, + "advance_at": [ + 400, + 85 + ] + }, + "uni0146": { + "shape": 0.028, + "shape_at": [ + 850, + 70 + ], + "advance": 14.6, + "advance_at": [ + 900, + 78 + ] + }, + "Ncaron": { + "shape": 0.0, + "shape_at": null, + "advance": 0.4, + "advance_at": [ + 400, + 85 + ] + }, + "ncaron": { + "shape": 0.0261, + "shape_at": [ + 850, + 70 + ], + "advance": 14.6, + "advance_at": [ + 900, + 78 + ] + }, + "napostrophe": { + "shape": 0.0245, + "shape_at": [ + 850, + 70 + ], + "advance": 14.6, + "advance_at": [ + 900, + 78 + ] + }, + "Omacron": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "omacron": { + "shape": 0.0076, + "shape_at": [ + 850, + 70 + ], + "advance": 13.3, + "advance_at": [ + 850, + 70 + ] + }, + "Obreve": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "obreve": { + "shape": 0.0075, + "shape_at": [ + 850, + 70 + ], + "advance": 13.3, + "advance_at": [ + 850, + 70 + ] + }, + "Ohungarumlaut": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "ohungarumlaut": { + "shape": 0.0069, + "shape_at": [ + 850, + 70 + ], + "advance": 13.3, + "advance_at": [ + 850, + 70 + ] + }, + "Racute": { + "shape": 0.0, + "shape_at": null, + "advance": 0.4, + "advance_at": [ + 400, + 85 + ] + }, + "racute": { + "shape": 0.0203, + "shape_at": [ + 900, + 120 + ], + "advance": 26.3, + "advance_at": [ + 900, + 120 + ] + }, + "uni0156": { + "shape": 0.0, + "shape_at": null, + "advance": 0.4, + "advance_at": [ + 400, + 85 + ] + }, + "uni0157": { + "shape": 0.019, + "shape_at": [ + 900, + 120 + ], + "advance": 26.3, + "advance_at": [ + 900, + 120 + ] + }, + "Rcaron": { + "shape": 0.0, + "shape_at": null, "advance": 0.4, "advance_at": [ 400, 85 ] }, - "ntilde": { - "shape": 0.049, + "rcaron": { + "shape": 0.0261, + "shape_at": [ + 900, + 120 + ], + "advance": 26.3, + "advance_at": [ + 900, + 120 + ] + }, + "Sacute": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 78 + ] + }, + "sacute": { + "shape": 0.0074, + "shape_at": [ + 800, + 78 + ], + "advance": 13.9, + "advance_at": [ + 800, + 78 + ] + }, + "Scircumflex": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 78 + ] + }, + "scircumflex": { + "shape": 0.0069, + "shape_at": [ + 800, + 78 + ], + "advance": 13.9, + "advance_at": [ + 800, + 78 + ] + }, + "Scedilla": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 78 + ] + }, + "scedilla": { + "shape": 0.0075, + "shape_at": [ + 800, + 78 + ], + "advance": 13.9, + "advance_at": [ + 800, + 78 + ] + }, + "Scaron": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 78 + ] + }, + "scaron": { + "shape": 0.0069, + "shape_at": [ + 800, + 78 + ], + "advance": 13.9, + "advance_at": [ + 800, + 78 + ] + }, + "uni0162": { + "shape": 0.0, + "shape_at": null, + "advance": 2.8, + "advance_at": [ + 850, + 70 + ] + }, + "uni0163": { + "shape": 0.0592, + "shape_at": [ + 550, + 85 + ], + "advance": 26.3, + "advance_at": [ + 900, + 120 + ] + }, + "Tcaron": { + "shape": 0.0, + "shape_at": null, + "advance": 2.8, + "advance_at": [ + 850, + 70 + ] + }, + "tcaron": { + "shape": 0.0333, "shape_at": [ - 700, + 550, 85 ], - "advance": 28.8, + "advance": 26.3, "advance_at": [ - 800, - 85 + 900, + 120 ] }, - "ograve": { - "shape": 0.0086, + "Utilde": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "utilde": { + "shape": 0.0229, "shape_at": [ - 850, - 70 + 900, + 78 ], - "advance": 13.3, + "advance": 14.6, "advance_at": [ - 850, - 70 + 800, + 78 ] }, - "oacute": { - "shape": 0.0086, + "Umacron": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "umacron": { + "shape": 0.035, "shape_at": [ 850, 70 ], - "advance": 13.3, + "advance": 14.6, "advance_at": [ - 850, - 70 + 900, + 78 ] }, - "ocircumflex": { - "shape": 0.0077, + "Ubreve": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "ubreve": { + "shape": 0.0346, "shape_at": [ 850, 70 ], - "advance": 13.3, + "advance": 14.6, "advance_at": [ - 850, - 70 + 900, + 78 ] }, - "otilde": { - "shape": 0.066, - "shape_at": [ - 800, - 85 - ], - "advance": 38.8, + "Uring": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, "advance_at": [ - 800, - 85 + 900, + 92 ] }, - "odieresis": { - "shape": 0.0123, + "uring": { + "shape": 0.0322, "shape_at": [ 850, 70 ], "advance": 14.6, "advance_at": [ - 850, - 70 + 900, + 78 ] }, - "ugrave": { + "Uhungarumlaut": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "uhungarumlaut": { "shape": 0.0319, "shape_at": [ 850, 70 ], - "advance": 15.7, + "advance": 14.6, "advance_at": [ 900, - 85 + 78 ] }, - "uacute": { - "shape": 0.0319, + "Uogonek": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 92 + ] + }, + "uogonek": { + "shape": 0.0406, "shape_at": [ 850, 70 ], - "advance": 15.7, + "advance": 14.6, + "advance_at": [ + 900, + 78 + ] + }, + "Wcircumflex": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, "advance_at": [ 900, + 92 + ] + }, + "wcircumflex": { + "shape": 0.0002, + "shape_at": [ + 900, + 78 + ], + "advance": 0.3, + "advance_at": [ + 100, 85 ] }, - "ucircumflex": { - "shape": 0.0288, + "Ycircumflex": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ + 900, + 78 + ] + }, + "ycircumflex": { + "shape": 0.0167, "shape_at": [ - 850, - 70 + 900, + 78 ], - "advance": 15.7, + "advance": 17.3, + "advance_at": [ + 900, + 78 + ] + }, + "Ydieresis": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, "advance_at": [ 900, + 78 + ] + }, + "Zacute": { + "shape": 0.0, + "shape_at": null, + "advance": 0.4, + "advance_at": [ + 400, 85 ] }, - "udieresis": { - "shape": 0.0387, + "zacute": { + "shape": 0.0221, "shape_at": [ - 900, + 800, + 78 + ], + "advance": 17.7, + "advance_at": [ + 800, + 78 + ] + }, + "Zdotaccent": { + "shape": 0.0, + "shape_at": null, + "advance": 0.4, + "advance_at": [ + 400, 85 + ] + }, + "zdotaccent": { + "shape": 0.0258, + "shape_at": [ + 800, + 78 ], - "advance": 20.2, + "advance": 17.7, "advance_at": [ - 900, + 800, + 78 + ] + }, + "Zcaron": { + "shape": 0.0, + "shape_at": null, + "advance": 0.4, + "advance_at": [ + 400, 85 ] }, - "yacute": { - "shape": 0.0226, + "zcaron": { + "shape": 0.0207, "shape_at": [ + 800, + 78 + ], + "advance": 17.7, + "advance_at": [ + 800, + 78 + ] + }, + "uni0218": { + "shape": 0.0, + "shape_at": null, + "advance": 0.3, + "advance_at": [ 900, + 78 + ] + }, + "uni0219": { + "shape": 0.0076, + "shape_at": [ + 800, + 78 + ], + "advance": 13.9, + "advance_at": [ + 800, + 78 + ] + }, + "uni021A": { + "shape": 0.0, + "shape_at": null, + "advance": 2.8, + "advance_at": [ + 850, + 70 + ] + }, + "uni021B": { + "shape": 0.0566, + "shape_at": [ + 550, 85 ], - "advance": 22.7, + "advance": 26.3, "advance_at": [ 900, + 120 + ] + }, + "circumflex": { + "shape": 0.0, + "shape_at": null, + "advance": 0.5, + "advance_at": [ + 100, 85 ] }, - "ydieresis": { - "shape": 0.0502, - "shape_at": [ + "caron": { + "shape": 0.0, + "shape_at": null, + "advance": 0.5, + "advance_at": [ + 100, + 85 + ] + }, + "breve": { + "shape": 0.0, + "shape_at": null, + "advance": 0.5, + "advance_at": [ 900, 85 - ], - "advance": 27.2, + ] + }, + "dotaccent": { + "shape": 0.0, + "shape_at": null, + "advance": 0.4, "advance_at": [ 900, 85 ] }, - "Ydieresis": { + "ring": { "shape": 0.0, "shape_at": null, - "advance": 0.3, + "advance": 0.4, + "advance_at": [ + 400, + 85 + ] + }, + "ogonek": { + "shape": 0.0, + "shape_at": null, + "advance": 0.5, "advance_at": [ 100, 85 ] + }, + "tilde": { + "shape": 0.0, + "shape_at": null, + "advance": 0.4, + "advance_at": [ + 100, + 85 + ] + }, + "hungarumlaut": { + "shape": 0.0, + "shape_at": null, + "advance": 0.4, + "advance_at": [ + 900, + 85 + ] } } } diff --git a/proofs/heddle/heddle-accents.png b/proofs/heddle/heddle-accents.png index 414caee..a6efb7c 100644 Binary files a/proofs/heddle/heddle-accents.png and b/proofs/heddle/heddle-accents.png differ diff --git a/proofs/heddle/heddle-before-after-accents.png b/proofs/heddle/heddle-before-after-accents.png new file mode 100644 index 0000000..3beb29d Binary files /dev/null and b/proofs/heddle/heddle-before-after-accents.png differ diff --git a/proofs/heddle/heddle-before-after-counters.png b/proofs/heddle/heddle-before-after-counters.png new file mode 100644 index 0000000..354f278 Binary files /dev/null and b/proofs/heddle/heddle-before-after-counters.png differ diff --git a/proofs/heddle/heddle-before-after-drift.png b/proofs/heddle/heddle-before-after-drift.png new file mode 100644 index 0000000..e1b3644 Binary files /dev/null and b/proofs/heddle/heddle-before-after-drift.png differ diff --git a/proofs/heddle/heddle-charset.png b/proofs/heddle/heddle-charset.png index 5390ee7..f8abf3c 100644 Binary files a/proofs/heddle/heddle-charset.png and b/proofs/heddle/heddle-charset.png differ diff --git a/proofs/heddle/heddle-designspace.png b/proofs/heddle/heddle-designspace.png index 8d401ec..4a309eb 100644 Binary files a/proofs/heddle/heddle-designspace.png and b/proofs/heddle/heddle-designspace.png differ diff --git a/proofs/heddle/heddle-kerning.png b/proofs/heddle/heddle-kerning.png index 2be6df5..bfa5a3d 100644 Binary files a/proofs/heddle/heddle-kerning.png and b/proofs/heddle/heddle-kerning.png differ diff --git a/proofs/heddle/heddle-latin-ext.png b/proofs/heddle/heddle-latin-ext.png new file mode 100644 index 0000000..d6abb9f Binary files /dev/null and b/proofs/heddle/heddle-latin-ext.png differ diff --git a/proofs/heddle/heddle-sketch-vs-font-charset.png b/proofs/heddle/heddle-sketch-vs-font-charset.png index b5023b4..11d74c4 100644 Binary files a/proofs/heddle/heddle-sketch-vs-font-charset.png and b/proofs/heddle/heddle-sketch-vs-font-charset.png differ diff --git a/proofs/heddle/heddle-sketch-vs-font-pangram.png b/proofs/heddle/heddle-sketch-vs-font-pangram.png index 542c3bb..3ba8fd2 100644 Binary files a/proofs/heddle/heddle-sketch-vs-font-pangram.png and b/proofs/heddle/heddle-sketch-vs-font-pangram.png differ diff --git a/proofs/heddle/heddle-symbols.png b/proofs/heddle/heddle-symbols.png new file mode 100644 index 0000000..be3528b Binary files /dev/null and b/proofs/heddle/heddle-symbols.png differ diff --git a/proofs/heddle/heddle-weight-waterfall.png b/proofs/heddle/heddle-weight-waterfall.png index 3d387bc..13cda49 100644 Binary files a/proofs/heddle/heddle-weight-waterfall.png and b/proofs/heddle/heddle-weight-waterfall.png differ diff --git a/proofs/heddle/heddle-width-ladder.png b/proofs/heddle/heddle-width-ladder.png index 0345a92..5e17e2b 100644 Binary files a/proofs/heddle/heddle-width-ladder.png and b/proofs/heddle/heddle-width-ladder.png differ diff --git a/proofs/heddle/readability.json b/proofs/heddle/readability.json new file mode 100644 index 0000000..444da0f --- /dev/null +++ b/proofs/heddle/readability.json @@ -0,0 +1,119 @@ +{ + "about": "OCR character error rate (CER), lower is better. Heddle is a display face: this stops regressions; it is not a text-face gate. See heddle/tools/readability.py.", + "tesseract": "tesseract 5.3.4", + "paragraph": "Swiss posters of 1958 set big words in tight grotesks. Heddle builds every letter from four parts: rect, ring, slant and vee. Quick zebras jump over the lazy dog; 27 boxes of 64 jugs weigh 3,590 kg.", + "sizes_px": [ + 24, + 32, + 48 + ], + "heddle": { + "font": "machine-made Heddle 0.2 (static TTF)", + "Regular": { + "cer_by_px": { + "24": 0.0051, + "32": 0.0, + "48": 0.0 + }, + "cer_mean": 0.0017 + }, + "Bold": { + "cer_by_px": { + "24": 0.0, + "32": 0.0, + "48": 0.0051 + }, + "cer_mean": 0.0017 + }, + "Black": { + "cer_by_px": { + "24": 0.0, + "32": 0.0, + "48": 0.0 + }, + "cer_mean": 0.0 + } + }, + "reference": { + "font": "DejaVu Sans (Book; Bold for Bold and Black)", + "Regular": { + "cer_by_px": { + "24": 0.0, + "32": 0.0, + "48": 0.0051 + }, + "cer_mean": 0.0017 + }, + "Bold": { + "cer_by_px": { + "24": 0.0, + "32": 0.0, + "48": 0.0 + }, + "cer_mean": 0.0 + }, + "Black": { + "cer_by_px": { + "24": 0.0, + "32": 0.0, + "48": 0.0 + }, + "cer_mean": 0.0 + } + }, + "gate": { + "margin_over_baseline": 0.01, + "margin_over_reference": 0.05, + "checks": [ + { + "style": "Regular", + "cer": 0.0017, + "against": "baseline", + "limit": 0.01, + "pass": true + }, + { + "style": "Bold", + "cer": 0.0017, + "against": "baseline", + "limit": 0.0134, + "pass": true + }, + { + "style": "Black", + "cer": 0.0, + "against": "baseline", + "limit": 0.01, + "pass": true + } + ], + "pass": true + }, + "before": { + "font": "Heddle 0.1", + "Regular": { + "cer_by_px": { + "24": 0.0, + "32": 0.0, + "48": 0.0 + }, + "cer_mean": 0.0 + }, + "Bold": { + "cer_by_px": { + "24": 0.0, + "32": 0.0051, + "48": 0.0051 + }, + "cer_mean": 0.0034 + }, + "Black": { + "cer_by_px": { + "24": 0.0, + "32": 0.0, + "48": 0.0 + }, + "cer_mean": 0.0 + } + } +} diff --git a/proofs/heddle/sketch-compare.json b/proofs/heddle/sketch-compare.json index b2fdc4c..9a934f7 100644 --- a/proofs/heddle/sketch-compare.json +++ b/proofs/heddle/sketch-compare.json @@ -1,20 +1,20 @@ { "pangram": { "100,70": 0.7503, - "900,70": 0.9717, + "900,70": 0.9649, "400,100": 0.9591, "100,140": 0.7739, - "900,140": 0.9682, - "250,85": 0.7337, - "700,100": 0.9736, - "650,120": 0.5158 + "900,140": 0.9626, + "250,85": 0.8615, + "700,100": 0.9719, + "650,120": 0.5154 }, "charset": { - "100,70": 0.7848, - "900,70": 0.9716, - "400,100": 0.9606, - "100,140": 0.8053, - "900,140": 0.9648 + "100,70": 0.7849, + "900,70": 0.9657, + "400,100": 0.9607, + "100,140": 0.8054, + "900,140": 0.9615 }, "note": "Soft IoU of the ink of the sketch render and the font render (1.0 = the same pixels). Rendered by tools/sketch_compare.py." } diff --git a/site/build.py b/site/build.py index a65e6a7..f26de3c 100644 --- a/site/build.py +++ b/site/build.py @@ -50,7 +50,7 @@ RELEASES = { "weft": REPO_URL + "/releases/tag/weft-v0.1.0", "sans": REPO_URL + "/releases/tag/sans-v0.1.0", - "heddle": REPO_URL + "/releases/tag/heddle-v0.1.0", + "heddle": REPO_URL + "/releases/tag/heddle-v0.2.0", "warp": REPO_URL + "/releases/tag/warp-v0.1.0", } diff --git a/site/pages/download/index.html b/site/pages/download/index.html index da30d61..21f1f88 100644 --- a/site/pages/download/index.html +++ b/site/pages/download/index.html @@ -1,6 +1,6 @@