Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,10 @@ docs/notes/
Thumbs.db
desktop.ini
.DS_Store

# Generated font CSS. web/embed-font.py writes web/fonts/face.css, and its
# contents get pasted straight into the <style> block of EDIWorkbench.html --
# so a committed copy is a second copy of bytes that already live in a tracked
# file, free to drift from the one the app actually serves. The script and the
# licence beside it ARE tracked; the intermediate is not.
web/fonts/face.css
996 changes: 727 additions & 269 deletions web/EDIWorkbench.html

Large diffs are not rendered by default.

160 changes: 160 additions & 0 deletions web/embed-font.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Subset Barlow Condensed and emit the @font-face CSS pasted into EDIWorkbench.html.

The app is one self-contained HTML file with no external requests -- lint.mjs
fails the suite on any src/href, fetch, XHR or socket -- so a webfont is either
embedded as a base64 data URI or not used at all. Barlow Condensed carries the
design's character in the wordmark, headings, card titles and tile numerals.

Full Google "latin" subsets are ~22 KB per weight: +24% on a 250 KB app, for
glyph coverage this app never renders. Cutting to the marks below lands near
7 KB per weight, ~9.7 KB once base64'd -- about +7.6% for both weights.

This is an authoring-time tool. Its output is a string baked into the HTML, so
the app keeps its zero-dependency, zero-build-step property: nothing here runs
at page load, and nothing here is needed to run the test suites.

Requires fontTools and brotli (brotli is what writes woff2):
python -m pip install fonttools brotli

Usage:
python web/embed-font.py SRC_600.woff2 SRC_700.woff2 > web/fonts/face.css

Sources: any Barlow Condensed woff2 at weights 600 and 700 -- the latin subsets
from Google Fonts, or the copies inside the design artifact under
docs/notes/design-sources/unpacked/ (gitignored).
"""

import base64
import io
import sys

from fontTools import subset
from fontTools.ttLib import TTFont

# Printable ASCII covers every heading, label and numeral the app renders in
# this family. The extras are the typographic marks the markup actually uses
# (&middot;, &mdash;, &ndash;, &hellip;, curly quotes) plus a few that turn up in
# decoded EDI values and check messages.
UNICODES = ",".join([
"U+0020-007E", # printable ASCII
"U+00A0", # no-break space
"U+00A9", # (c)
"U+00B0", # degree
"U+00B7", # middle dot -- the wordmark and tagline separators
"U+00D7", # multiplication sign
"U+2013-2014", # en dash, em dash
"U+2018-201D", # curly quotes
"U+2022", # bullet
"U+2026", # ellipsis
"U+2192", # right arrow -- before/after captions
"U+2212", # true minus, for tabular figures
"U+2713", # check mark
])

# Barlow is published under the SIL Open Font License 1.1 with NO Reserved Font
# Name -- its copyright string carries no "with Reserved Font Name" clause. That
# is what makes it legal to keep the family name on a subset; a Modified Version
# of a reserved-name family would have to be renamed.
#
# OFL section 1 requires the copyright and permission notices to travel with
# every copy, and states they "can be included either as text files or in
# machine-readable metadata fields". The single HTML file travels detached from
# this repo, so the repo's copy of the licence cannot be what satisfies that --
# the notices have to ride inside the font, which is what the name records below
# do. web/fonts/OFL.txt keeps the full text for the repo.
LICENSE_NOTICE = (
"This Font Software is licensed under the SIL Open Font License, "
"Version 1.1. This licence is available with a FAQ at "
"https://scripts.sil.org/OFL"
)
LICENSE_URL = "https://scripts.sil.org/OFL"

NAME_COPYRIGHT, NAME_LICENSE, NAME_LICENSE_URL = 0, 13, 14

# Windows/Unicode/US-English, the platform triple every browser reads.
WIN_UNICODE_EN = (3, 1, 0x409)


def parse_unicodes(spec):
"""Expand a 'U+0020-007E,U+00A9' spec into a set of codepoints."""
points = set()
for part in spec.split(","):
part = part.strip().removeprefix("U+")
if "-" in part:
low, high = part.split("-")
points.update(range(int(low, 16), int(high, 16) + 1))
else:
points.add(int(part, 16))
return points


def build(src_path):
"""Subset src_path to UNICODES and return (woff2 bytes, copyright string)."""
font = TTFont(src_path)

options = subset.Options()
# Keep kerning; drop the rest. This family's other features (figure
# variants, alternates) are never selected by the app's CSS, and each one
# drags in a glyph set of its own.
options.layout_features = ["kern"]
options.hinting = False
options.desubroutinize = True
# Without 13 and 14 here the subsetter keeps only name IDs 0-6, dropping the
# licence records this font is required to carry.
options.name_IDs = [0, 1, 2, 3, 4, 5, 6, 13, 14]

subsetter = subset.Subsetter(options=options)
subsetter.populate(unicodes=parse_unicodes(UNICODES))
subsetter.subset(font)

copyright_notice = font["name"].getDebugName(NAME_COPYRIGHT)
if not copyright_notice:
raise SystemExit(f"{src_path}: no copyright record -- refusing to embed")

# Google's webfont builds carry the copyright but no licence notice. Set
# both explicitly so the embedded font states its own terms wherever the
# HTML file ends up.
font["name"].setName(LICENSE_NOTICE, NAME_LICENSE, *WIN_UNICODE_EN)
font["name"].setName(LICENSE_URL, NAME_LICENSE_URL, *WIN_UNICODE_EN)

font.flavor = "woff2"
buffer = io.BytesIO()
font.save(buffer)
return buffer.getvalue(), copyright_notice


def main(argv):
if len(argv) != 3:
raise SystemExit(__doc__)

faces, notice = [], None
for weight, src in zip((600, 700), argv[1:]):
woff2, copyright_notice = build(src)
notice = notice or copyright_notice
encoded = base64.b64encode(woff2).decode("ascii")
# Sizes go to stderr so stdout stays pasteable CSS.
print(
f"Barlow Condensed {weight}: {len(woff2):,} bytes woff2, "
f"{len(encoded):,} base64",
file=sys.stderr,
)
faces.append(
" @font-face {\n"
" font-family: 'Barlow Condensed';\n"
" font-style: normal;\n"
f" font-weight: {weight};\n"
" font-display: swap;\n"
f" src: url(data:font/woff2;base64,{encoded}) format('woff2');\n"
" }"
)

print(f" /* {notice}")
print(f" {LICENSE_NOTICE}")
print(" Subset to the glyphs this app renders, by web/embed-font.py.")
print(" Full licence text: web/fonts/OFL.txt */")
print("\n".join(faces))


if __name__ == "__main__":
main(sys.argv)
94 changes: 94 additions & 0 deletions web/fonts/OFL.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
Copyright (c) 2017, The Barlow Project Authors
(https://github.com/jpt/barlow).

This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org


-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------

PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.

The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.

DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.

"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).

"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).

"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.

"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.

PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:

1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.

2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.

3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.

4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.

5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.

TERMINATION
This license becomes null and void if any of the above conditions are
not met.

DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
1 change: 1 addition & 0 deletions web/tests/all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const SUITES = [
["validate", "control counts, claim balance, and count repair"],
["deid", "patient de-identification, consistency, and date shifting"],
["virtual", "virtual list windowing arithmetic"],
["render", "the load path, driven against a throwaway DOM"],
["lint", "single-file self-containment and markup"],
];

Expand Down
29 changes: 17 additions & 12 deletions web/tests/lint.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ const blocks = {
'data-theme="light"': css.match(/:root\[data-theme="light"\] \{([\s\S]*?)\n \}/),
};
const colorTokens = (body) =>
new Set([...body.matchAll(/(--[\w-]+):/g)].map((x) => x[1]).filter((t) => !t.startsWith("--step") && t !== "--mono" && t !== "--sans"));
new Set([...body.matchAll(/(--[\w-]+):/g)].map((x) => x[1]).filter((t) => !t.startsWith("--step") && !["--mono", "--sans", "--display"].includes(t)));
const base = colorTokens(blocks[":root"][1]);
for (const [name, match] of Object.entries(blocks)) {
if (name === ":root") continue;
Expand All @@ -107,17 +107,22 @@ else if (!/max-height:/.test(outlineRule[1])) bad(".outline must cap its height,
else if (!/overflow-y:\s*auto/.test(outlineRule[1])) bad(".outline caps its height but cannot scroll to its own bottom");
else ok(".outline caps its height and scrolls itself");

/* --- 8. the rail masthead stays pinned ------------------------------ */
// The mask and theme toggles act on whatever is on screen at the time, so
// they have to be reachable at any scroll position. They were inside the
// scroll container until the rail was split, and reaching the mask button
// meant scrolling the rules back up first. Two invariants keep that fixed:
// the masthead sits outside .rail-body, and .rail-body is what scrolls.
const railBody = markup.slice(markup.indexOf('class="rail-body"'), markup.indexOf("</aside>"));
if (markup.indexOf('class="rail-body"') === -1) bad("no .rail-body found: the rail no longer splits head from body");
else if (railBody.includes('class="masthead"')) bad("the masthead is inside .rail-body, so it scrolls out of reach");
else if (!markup.includes('class="rail-head"')) bad("no .rail-head wrapper around the masthead");
else ok("the masthead sits outside the rail's scroll container");
/* --- 8. the mask and theme toggles stay reachable -------------------- */
// They act on whatever is on screen at the time, so they have to be reachable
// at any scroll position. They lived inside the rail's scroll container once,
// and reaching the mask button meant scrolling the rules back up first; the
// rail was split into a pinned head and a scrolling body to fix that. They now
// sit in the page header instead, which is unconditionally reachable -- so the
// invariant is no longer "outside .rail-body" but "outside the rail entirely".
// Keep it asserted: this is a bug that has already happened once.
const rail = markup.slice(markup.indexOf("<aside class=\"rail\""), markup.indexOf("</aside>"));
const topbar = markup.slice(markup.indexOf('<header class="topbar"'), markup.indexOf("</header>"));
if (!topbar) bad("no .topbar found: the masthead has nowhere pinned to live");
else if (rail.includes('id="maskBtn"') || rail.includes('id="themeBtn"'))
bad("a toggle is back inside the rail, where it scrolls out of reach");
else if (!topbar.includes('id="maskBtn"') || !topbar.includes('id="themeBtn"'))
bad("the mask or theme toggle is not in the page header");
else ok("the mask and theme toggles sit in the pinned page header");

const railBodyRule = css.match(/\.rail-body \{([\s\S]*?)\n \}/);
if (!railBodyRule) bad("no .rail-body rule found");
Expand Down
Loading