diff --git a/docs/art/edge-evidence.svg b/docs/art/edge-evidence.svg index fd13942..5ecc0b6 100644 --- a/docs/art/edge-evidence.svg +++ b/docs/art/edge-evidence.svg @@ -14,4 +14,4 @@ .k{ fill:var(--muted); font-size:11px; letter-spacing:.16em; } .h{ fill:var(--bone); font-size:21px; font-weight:700; } .thin{ stroke:var(--hairline); stroke-width:1.2; fill:none; } -DECLARED, NEVER PROBEDWhat one wiring edge settles, and what it only repeats$ plexus discover --dir manifestsKEYWHAT SETTLED ITWHAT IT DOES NOT SETTLEproducerCOMPUTEDThe organ whose emitted port satisfied the name. Only organs in themanifest set this run loaded can appear.consumerCOMPUTEDThe organ that named this capability among its own inputs.capabilityCOMPUTEDThe consumer's input name. A port satisfies it by equality, or bynaming it in consumable_as.self_loopCOMPUTEDTrue when both ends are the same organ. Reported rather than dropped,so a cycle stays visible in the plan.viaCOPIEDThe producer's own pointer at the code behind the port. plexus neverresolves it, so a path at nothing reads as a real one.evidenceCONSTANTAlways declared. The field is here to say the edge was matched onstrings and that no tool was run.An edge forms by matching capability strings the manifests report about themselves. That is enough to settle who names what, and it is not enough to settle that thecited module exists or that the two sides compose. The edge carries a second pointer, the consumer's own module, which discover holds and never serializes, so it isnot drawn here either. +DECLARED, NEVER PROBEDWhat one wiring edge settles, and what it only repeats$ plexus discover --dir manifestsKEYWHAT SETTLED ITWHAT IT DOES NOT SETTLEproducerCOMPUTEDThe organ whose emitted port satisfied the name. Only organsin the manifest set this run loaded can appear.consumerCOMPUTEDThe organ that named this capability among its own inputs.capabilityCOMPUTEDThe consumer's input name. A port satisfies it by equality, or bynaming it in consumable_as.self_loopCOMPUTEDTrue when both ends are the same organ. Reported rather thandropped, so a cycle stays visible in the plan.viaCOPIEDThe producer's own pointer at the code behind the port. plexusnever resolves it, so a path at nothing reads as a real one.evidenceCONSTANTAlways declared. The field is here to say the edge was matchedon strings and that no tool was run.An edge forms by matching capability strings the manifests report about themselves. That is enough to settle who names what, and it is not enoughto settle that the cited module exists or that the two sides compose. The edge carries a second pointer, the consumer's own module, which discoverholds and never serializes, so it is not drawn here either. diff --git a/tests/test_repo_art.py b/tests/test_repo_art.py index ebefef0..eaa6ab4 100644 --- a/tests/test_repo_art.py +++ b/tests/test_repo_art.py @@ -28,6 +28,8 @@ "art.outcome_fits_its_box", "art.card_draws_shapes_not_digits", "art.card_text_fits_its_column", + "art.card_widths_bound_every_face", + "art.card_draws_measured_characters", "art.card_carries_one_mark", "art.card_alt_reaches_the_readme", "art.the_gate_can_fail", diff --git a/tools/check_repo_card.py b/tools/check_repo_card.py index ca9c73d..936aa93 100644 --- a/tools/check_repo_card.py +++ b/tools/check_repo_card.py @@ -6,6 +6,12 @@ run's worth of digits. A picture with a hash in it is wrong by the next commit, so a hash may not be drawn at all. +The measuring happens here, from face-metrics.json, rather than by calling the +renderer. An earlier version asked repo_card how wide its own text was, so the +check and the drawing shared a single guess and agreed with each other +whatever that guess said. This walks each measured face on its own and asks +whether the line fits in that face, so the two can now disagree. + Whether the drawn fields are TRUE of the record is a different question, and it is asked where the record lives rather than here. @@ -28,17 +34,32 @@ DIGEST = re.compile(r"[0-9a-f]{12,}") BIG_NUMBER = re.compile(r"\d{5,}") -# The two mono columns, in characters. A monospace advance is about 0.6em, so -# these count characters against the width each column actually has. -KEY_BUDGET = int((CARD.KEY_W + CARD.GUTTER - 16) / 7.8) -VAL_BUDGET = int(CARD.VAL_W / 7.2) +FACES = json.loads( + Path(__file__).with_name("face-metrics.json").read_text(encoding="utf-8")) +FIRST, LAST = FACES["range"] + +# What draws each element: which font stack, which weight, its size in pixels +# and its letter-spacing in em. Read off the card's own style block and off the +# two elements that override the family on themselves, since the class cannot. +DRAWN = { + "note": ("sans", "regular", 11.5, 0.0), + "foot": ("sans", "regular", 11.5, 0.0), + "title": ("sans", "bold", 21.0, 0.0), + "kicker": ("mono", "regular", 11.0, 0.16), + "head": ("mono", "regular", 11.0, 0.16), + "source": ("mono", "regular", 11.5, 0.0), + "key": ("mono", "bold", 13.0, 0.0), + "value": ("mono", "regular", 12.0, 0.0), +} +BOUND = {("sans", "regular"): CARD.SANS, ("sans", "bold"): CARD.SANS_BOLD, + ("mono", "regular"): CARD.MONO_REG, ("mono", "bold"): CARD.MONO_BOLD} -# The three column heads, in characters. They are set at 11px with a sixth of -# an em of tracking, so they run wider per character than the columns under -# them and get their own count. -HEAD_BUDGETS = (int((CARD.KEY_W + CARD.GUTTER - 16) / 8.4), - int((CARD.VAL_W + CARD.GUTTER) / 8.4), - int(CARD.NOTE_W / 8.4)) +# What each column has room for, in pixels rather than in characters. Conso +# is not monospaced, so a count was never the right unit for these two either. +KEY_BUDGET = CARD.KEY_W + CARD.GUTTER - 16 +VAL_BUDGET = CARD.VAL_W +HEAD_BUDGETS = (KEY_BUDGET, CARD.VAL_W + CARD.GUTTER, CARD.NOTE_W) +PAGE_BUDGET = CARD.W - CARD.PAD * 2 def _cards() -> list[dict]: @@ -47,18 +68,60 @@ def _cards() -> list[dict]: .get("cards", [])] -def values_that_are_not_shapes(cards: list[dict]) -> list[str]: - """A value column holds the shape of a field, never a run of its digits.""" +def _widest_face(text: str, role: str) -> tuple[str, float]: + """The face that draws this string widest, and what it draws, in pixels.""" + group, weight, size, tracking = DRAWN[role] + drawn = {} + for name, face in FACES[group][weight].items(): + off = max(face) + total = sum(face[ord(c) - FIRST] if FIRST <= ord(c) <= LAST else off + for c in text) + drawn[name] = total / 1000.0 * size + tracking * size * len(text) + return max(drawn.items(), key=lambda pair: pair[1]) + + +def _over(text: str, role: str, budget: float, label: str) -> list[str]: + name, width = _widest_face(text, role) + if width <= budget: + return [] + return [f"{label} draws {width:.0f}px into a {budget:.0f}px column " + f"in {name}: {text!r}"] + + +def _lines_that_run_long(text: str, label: str, role: str, budget: float, + limit: int) -> list[str]: + """Two ways a wrapped column goes wrong, and the second is the one a + check on the joined text misses. Dropping the ending is the obvious one. + The other is a single token longer than the budget: the wrapper is greedy, + so it leaves that token alone on its line, the joined text still equals + the source, and the drawing runs off the page with every check green.""" + drawn = CARD._wrap(text, budget, limit) bad = [] - for card in cards: - for field in card["fields"]: - value = field["value"] - if DIGEST.search(value): - bad.append(f'{card["file"]}: {field["key"]} draws a digest, ' - f"and a digest is true for one checkout: {value!r}") - if BIG_NUMBER.search(value): - bad.append(f'{card["file"]}: {field["key"]} draws a number ' - f"that moves with the commit: {value!r}") + if " ".join(drawn) != " ".join(text.split()): + bad.append(f"{label} loses its ending") + for line in drawn: + bad += _over(line, role, budget, label) + return bad + + +def _card_overflows(card: dict) -> list[str]: + """Every string one card draws, against the room it is drawn in.""" + where = card["file"] + bad = [] + for field in card["fields"]: + bad += _over(field["key"], "key", KEY_BUDGET, f"{where}: the key") + bad += _over(field["value"], "value", VAL_BUDGET, + f'{where}: the value on {field["key"]}') + bad += _lines_that_run_long( + field["note"], f'{where}: the note on {field["key"]}', "note", + CARD.NOTE_BUDGET, CARD.NOTE_LINES) + bad += _lines_that_run_long(card["footnote"], f"{where}: the footnote", + "foot", CARD.FOOT_BUDGET, CARD.FOOT_LINES) + for label, role, text in ( + ("the title", "title", card.get("title", "")), + ("the kicker", "kicker", card.get("kicker", "").upper()), + ("the source line", "source", "$ " + card.get("source", ""))): + bad += _over(text, role, PAGE_BUDGET, f"{where}: {label}") return bad @@ -69,29 +132,67 @@ def text_that_overflows(cards: list[dict]) -> list[str]: width and then drop what will not fit instead of growing the drawing.""" bad = [] for card in cards: - for field in card["fields"]: - if len(field["key"]) > KEY_BUDGET: - bad.append(f'{card["file"]}: the {field["key"]} name runs ' - f"into the value column") - if len(field["value"]) > VAL_BUDGET: - bad.append(f'{card["file"]}: the value on {field["key"]} runs ' - f"into the note column") - drawn = " ".join(CARD._wrap(field["note"])) - if drawn != " ".join(field["note"].split()): - bad.append(f'{card["file"]}: the note on {field["key"]} cuts ' - f'off at "{drawn}"') + bad += _card_overflows(card) heads = card.get("heads", CARD.HEADS) if len(heads) != 3: bad.append(f'{card["file"]} names {len(heads)} columns, and the ' f"drawing has three") for head, budget in zip(heads, HEAD_BUDGETS): - if len(head) > budget: - bad.append(f'{card["file"]}: the {head!r} column head runs ' - f"into the column beside it") - foot = " ".join(CARD._wrap(card["footnote"], CARD.FOOT_BUDGET, - CARD.FOOT_LINES)) - if foot != " ".join(card["footnote"].split()): - bad.append(f'{card["file"]}: the footnote cuts off at "{foot}"') + bad += _over(head.upper(), "head", budget, + f'{card["file"]}: the {head!r} column head') + return bad + + +def faces_the_renderer_underestimates(bounds: dict | None = None) -> list[str]: + """The renderer wraps to one table and this file measures with another, + and that is what keeps the two honest. For every character, what the + renderer assumes has to be at least what each measured face draws. A width + guessed from a character's class fails here on the first lowercase m.""" + bad = [] + for (group, weight), bound in (bounds or BOUND).items(): + if len(bound) != LAST - FIRST + 1: + bad.append(f"the {group} {weight} bound covers {len(bound)} " + f"characters and the faces cover {LAST - FIRST + 1}") + continue + for name, face in FACES[group][weight].items(): + for i, thousandths in enumerate(face): + if bound[i] < thousandths / 1000.0 - 1e-9: + bad.append(f"{name} draws {chr(FIRST + i)!r} wider than " + f"the renderer assumes it does") + return bad + + +def characters_never_measured(cards: list[dict]) -> list[str]: + """A character outside the measured range falls back to the widest glyph + in the face, which is a guess wearing a measurement's clothes. Cards are + written in ASCII so that never has to happen, and this holds them to it.""" + bad = [] + for card in cards: + drawn = [card.get("title", ""), card.get("kicker", ""), + card.get("source", ""), card["footnote"], + *card.get("heads", ())] + for field in card["fields"]: + drawn += [field["key"], field["value"], field["note"]] + off = sorted({c for text in drawn for c in text + if not FIRST <= ord(c) <= LAST}) + if off: + bad.append(f'{card["file"]} draws {off!r}, and no face here was ' + f"measured for it") + return bad + + +def values_that_are_not_shapes(cards: list[dict]) -> list[str]: + """A value column holds the shape of a field, never a run of its digits.""" + bad = [] + for card in cards: + for field in card["fields"]: + value = field["value"] + if DIGEST.search(value): + bad.append(f'{card["file"]}: {field["key"]} draws a digest, ' + f"and a digest is true for one checkout: {value!r}") + if BIG_NUMBER.search(value): + bad.append(f'{card["file"]}: {field["key"]} draws a number ' + f"that moves with the commit: {value!r}") return bad @@ -99,9 +200,7 @@ def alt_text_that_drifted(cards: list[dict]) -> list[str]: """The README alt attribute is the whole of what a reader who cannot see the card gets. GitHub draws it as an , and an hides whatever description the SVG carries inside it, so the long one in the spec has to - reach the README as it is written. Without this, a row can be re-worded - and the sentence describing it to a screen reader still says what the card - used to say.""" + reach the README as written, or a re-worded row keeps its old sentence.""" shown = (ROOT / "README.md").read_text(encoding="utf-8") return [f'{card["file"]}: the README describes it as something it is no ' f"longer, because the spec alt is not the alt in the README" @@ -127,6 +226,10 @@ def checks() -> list[tuple]: lambda _unused: values_that_are_not_shapes(_cards())), ("art.card_text_fits_its_column", lambda _unused: text_that_overflows(_cards())), + ("art.card_widths_bound_every_face", + lambda _unused: faces_the_renderer_underestimates()), + ("art.card_draws_measured_characters", + lambda _unused: characters_never_measured(_cards())), ("art.card_carries_one_mark", lambda _unused: wrong_number_of_marks(_cards())), ("art.card_alt_reaches_the_readme", @@ -134,42 +237,50 @@ def checks() -> list[tuple]: ] -# A card built to break every one of those at once: a digest and a byte count -# in the value column, a name and a value too wide for their columns, a -# clipped note, a clipped footnote, and two hot marks where the rule allows -# one. -# -# The fourth row is the shape that got past an earlier version of this file. A -# budget counted in characters read that note as two comfortable lines and let -# it through, and it drew forty pixels past the edge of the page, because -# capitals are wider than the lowercase prose the count was calibrated on. It -# stays here so a return to counting characters fails rather than ships. +# A card built to break every one of those at once. The last row is what got +# past an earlier version of this file: a token too long to wrap. The wrapper +# leaves it alone on its line, so a check for cut text sees a clean wrap. CONTROL = [{ "file": "control.svg", "alt": "a description of a drawing that is in no README anywhere", "footnote": "word " * 200, - "heads": ["z" * (HEAD_BUDGETS[0] + 1), "ok", "ok", "one column too many"], + "heads": ["z" * 40, "ok", "ok", "one column too many \u00e9"], "fields": [ {"key": "head", "value": "9f2c4ab71de0", "note": "ok", "tone": "verified"}, {"key": "bytes", "value": "104857 bytes", "note": "ok", "tone": "drift"}, - {"key": "z" * (KEY_BUDGET + 1), "value": "z" * (VAL_BUDGET + 1), - "note": "word " * 40}, - {"key": "caps", "value": "ok", "note": " ".join(["UNVERIFIABLE"] * 10)}, + {"key": "z" * 40, "value": "z" * 60, "note": "word " * 40}, + {"key": "token", "value": "ok", "note": "x" * 120}, ], }] +# The bound the renderer used to carry, in the place it hurt most: a lowercase +# m given the width of an average lowercase letter. Every measured sans face +# draws one wider than that, and that is what holds the renderer to real ones. +CONTROL_BOUND = {("sans", "regular"): tuple( + 0.0 if i == ord("m") - FIRST else width + for i, width in enumerate(CARD.SANS))} + +# Written down rather than counted, so a number cannot drift quietly. +CONTROL_OVERFLOWS = 7 +CONTROL_UNDERBOUNDS = 4 + def control_failures() -> list[str]: """Feed each card gate input it has to reject, and say what got past.""" return [f"the gate missed {what}" for caught, what in ( (len(values_that_are_not_shapes(CONTROL)) == 2, "a digest and a byte count drawn as values"), - (len(text_that_overflows(CONTROL)) == 7, - "an over-wide name, an over-wide value, a clipped note, a row of " - "capitals that fits a character count and not the column, a fourth " - "column, an over-wide column head and a clipped footnote"), + (len(text_that_overflows(CONTROL)) == CONTROL_OVERFLOWS, + "an over-wide name, an over-wide value, a clipped note, a token " + "too long to wrap, a fourth column, an over-wide column head and a " + "clipped footnote"), + (len(faces_the_renderer_underestimates(CONTROL_BOUND)) + == CONTROL_UNDERBOUNDS, + "a renderer that assumes a lowercase m is no wider than average"), + (len(characters_never_measured(CONTROL)) == 1, + "a character no face here was measured for"), (len(wrong_number_of_marks(CONTROL)) == 1, "a card wearing two hot marks"), (len(alt_text_that_drifted(CONTROL)) == 1, diff --git a/tools/face-metrics.json b/tools/face-metrics.json new file mode 100644 index 0000000..a237cef --- /dev/null +++ b/tools/face-metrics.json @@ -0,0 +1,51 @@ +{ + "what": "Advance widths for the faces the card's font stacks can select.", + "why": "A reader's machine picks one of these, and the drawing has to fit in whichever one it picks. A check that asks the renderer how wide its own text is agrees with itself no matter what it draws, so it asks these instead.", + "unit": "thousandths of an em, rounded up", + "range": [32, 126], + "measured_with": "fontTools.ttLib, hmtx advance over head.unitsPerEm", + "sans": { + "regular": { + "Hanken Grotesk": [260, 240, 338, 699, 560, 747, 686, 174, 222, 222, 398, 560, 240, 327, 240, 418, 560, 560, 560, 560, 560, 560, 560, 560, 560, 560, 240, 240, 560, 560, 560, 516, 874, 637, 601, 704, 682, 585, 565, 731, 678, 246, 552, 634, 495, 839, 676, 748, 554, 774, 598, 564, 587, 656, 656, 957, 641, 602, 582, 258, 448, 258, 552, 405, 292, 540, 587, 518, 587, 540, 335, 514, 547, 226, 226, 532, 265, 828, 547, 577, 587, 587, 407, 470, 346, 547, 521, 748, 520, 521, 472, 239, 258, 239, 560], + "Segoe UI": [274, 285, 393, 591, 540, 819, 801, 230, 302, 302, 417, 685, 217, 400, 217, 390, 540, 540, 540, 540, 540, 540, 540, 540, 540, 540, 217, 217, 685, 685, 685, 449, 956, 646, 574, 620, 702, 506, 489, 687, 710, 267, 357, 581, 471, 898, 749, 754, 561, 754, 599, 532, 524, 688, 622, 935, 590, 553, 571, 302, 379, 302, 685, 416, 269, 509, 588, 462, 589, 523, 313, 589, 566, 243, 243, 498, 243, 862, 566, 586, 588, 589, 348, 425, 339, 566, 480, 723, 459, 484, 453, 302, 240, 302, 685], + "Arial": [278, 278, 355, 557, 557, 890, 667, 191, 334, 334, 390, 584, 278, 334, 278, 278, 557, 557, 557, 557, 557, 557, 557, 557, 557, 557, 278, 278, 584, 584, 584, 557, 1016, 667, 667, 723, 723, 667, 611, 778, 723, 278, 500, 667, 557, 834, 723, 778, 667, 778, 723, 667, 611, 723, 667, 944, 667, 667, 611, 278, 278, 278, 470, 557, 334, 557, 557, 500, 557, 557, 278, 557, 557, 223, 223, 500, 223, 834, 557, 557, 557, 557, 334, 500, 278, 557, 500, 723, 500, 500, 500, 334, 260, 334, 584], + "DejaVu Sans": [318, 401, 460, 838, 637, 951, 780, 275, 391, 391, 500, 838, 318, 361, 318, 337, 637, 637, 637, 637, 637, 637, 637, 637, 637, 637, 337, 337, 838, 838, 838, 531, 1000, 685, 687, 699, 771, 632, 576, 775, 752, 295, 295, 656, 558, 863, 749, 788, 604, 788, 695, 635, 611, 732, 685, 989, 686, 611, 686, 391, 337, 391, 838, 500, 500, 613, 635, 550, 635, 616, 353, 635, 634, 278, 278, 580, 278, 975, 634, 612, 635, 635, 412, 521, 393, 634, 592, 818, 592, 592, 525, 637, 337, 637, 838] + }, + "bold": { + "Hanken Grotesk": [260, 252, 366, 694, 560, 735, 700, 193, 277, 277, 399, 560, 240, 327, 240, 420, 560, 560, 560, 560, 560, 560, 560, 560, 560, 560, 240, 240, 560, 560, 560, 515, 873, 674, 611, 709, 674, 578, 558, 735, 693, 263, 556, 663, 504, 856, 689, 747, 562, 785, 612, 575, 584, 657, 672, 979, 670, 625, 605, 299, 467, 299, 627, 493, 312, 546, 574, 522, 574, 541, 348, 522, 564, 245, 245, 560, 278, 843, 564, 572, 574, 574, 426, 472, 367, 564, 535, 755, 542, 535, 471, 283, 271, 283, 560], + "Segoe UI": [276, 328, 494, 593, 576, 868, 850, 293, 370, 370, 456, 708, 271, 405, 271, 444, 576, 576, 576, 576, 576, 576, 576, 576, 576, 576, 271, 271, 708, 708, 708, 438, 955, 704, 642, 625, 738, 533, 521, 711, 767, 317, 446, 649, 512, 958, 791, 759, 615, 759, 653, 561, 586, 724, 667, 1005, 656, 607, 607, 370, 437, 370, 708, 416, 314, 539, 621, 480, 620, 542, 384, 620, 603, 285, 285, 560, 285, 917, 605, 612, 621, 620, 398, 440, 390, 605, 542, 798, 553, 539, 480, 370, 327, 370, 708], + "Arial": [278, 334, 475, 557, 557, 890, 723, 238, 334, 334, 390, 584, 278, 334, 278, 278, 557, 557, 557, 557, 557, 557, 557, 557, 557, 557, 334, 334, 584, 584, 584, 611, 976, 723, 723, 723, 723, 667, 611, 778, 723, 278, 557, 723, 611, 834, 723, 778, 667, 778, 723, 667, 611, 723, 667, 944, 667, 667, 611, 334, 278, 334, 584, 557, 334, 557, 611, 557, 611, 557, 334, 611, 611, 278, 278, 557, 278, 890, 611, 611, 611, 611, 390, 557, 334, 611, 557, 778, 557, 557, 500, 390, 280, 390, 584], + "DejaVu Sans": [349, 457, 521, 838, 696, 1002, 873, 307, 458, 458, 523, 838, 380, 416, 380, 366, 696, 696, 696, 696, 696, 696, 696, 696, 696, 696, 400, 400, 838, 838, 838, 581, 1000, 774, 763, 734, 831, 684, 684, 821, 837, 373, 373, 775, 638, 996, 837, 851, 733, 851, 771, 721, 683, 813, 774, 1104, 771, 725, 726, 458, 366, 458, 838, 500, 500, 675, 716, 593, 716, 679, 436, 716, 712, 343, 343, 666, 343, 1042, 712, 688, 716, 716, 494, 596, 479, 712, 652, 924, 646, 652, 583, 712, 366, 712, 838] + } + }, + "mono": { + "regular": { + "Conso": [306, 244, 340, 753, 673, 720, 762, 220, 322, 322, 387, 553, 229, 466, 229, 537, 687, 309, 581, 625, 548, 610, 622, 534, 611, 622, 228, 229, 541, 553, 541, 545, 888, 728, 727, 820, 773, 659, 629, 850, 721, 274, 682, 744, 637, 1018, 720, 914, 667, 914, 717, 679, 754, 721, 728, 1099, 742, 710, 724, 301, 485, 301, 349, 616, 279, 630, 654, 636, 655, 647, 309, 635, 617, 264, 264, 599, 254, 931, 617, 684, 649, 657, 495, 529, 337, 617, 604, 925, 634, 604, 618, 321, 159, 321, 557], + "Cascadia Mono": [586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586, 586], + "Consolas": [550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550], + "DejaVu Sans Mono": [603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603] + }, + "bold": { + "Conso": [338, 280, 359, 775, 748, 754, 813, 239, 366, 366, 411, 561, 248, 482, 248, 561, 721, 351, 609, 646, 600, 629, 639, 550, 640, 639, 247, 248, 553, 561, 553, 582, 892, 781, 781, 835, 801, 701, 671, 884, 772, 306, 756, 808, 665, 1075, 736, 934, 729, 934, 782, 743, 812, 767, 781, 1173, 809, 774, 796, 347, 508, 347, 361, 632, 295, 668, 690, 642, 690, 665, 337, 647, 654, 289, 289, 675, 286, 953, 654, 693, 680, 691, 551, 581, 382, 654, 649, 960, 666, 649, 644, 353, 175, 353, 565], + "Consolas": [550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550, 550], + "DejaVu Sans Mono": [603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603, 603] + } + }, + "provenance": [ + {"family": "Hanken Grotesk", "weight": "regular", "file": "hanken-grotesk-regular.ttf", "sha256": "4b46b806d06ef9be5639b0de5a20774b21956165a9495a80610c77432b417ecb"}, + {"family": "Hanken Grotesk", "weight": "bold", "file": "hanken-grotesk-bold.ttf", "sha256": "4ca80ac105e80a582f892b3bbdd67dba26905f8bdefc837cb8a8908ad7be7fc9"}, + {"family": "Segoe UI", "weight": "regular", "file": "segoeui.ttf", "sha256": "8134dbcd09e7b123c9a7f229d49cffbcb01352cc72ea5e1076b65d0dca9f73cd"}, + {"family": "Segoe UI", "weight": "bold", "file": "segoeuib.ttf", "sha256": "aeb9e4a6ec5cc59f4d72df8189032d7dbb28f45161cf1552174818b5465dac4e"}, + {"family": "Arial", "weight": "regular", "file": "arial.ttf", "sha256": "b3658eadae55e682b5f69eb64c439c1ecc8f196c0bb8d4756d145d13bc86476a"}, + {"family": "Arial", "weight": "bold", "file": "arialbd.ttf", "sha256": "e8f4e3baf6cc35fed6fcce3a540e8b39e8f6cda1d22a28f2ec8f526fef7a43f5"}, + {"family": "DejaVu Sans", "weight": "regular", "file": "DejaVuSans.ttf", "sha256": "3fdf69cabf06049ea70a00b5919340e2ce1e6d02b0cc3c4b44fb6801bd1e0d22"}, + {"family": "DejaVu Sans", "weight": "bold", "file": "DejaVuSans-Bold.ttf", "sha256": "b184b89e3c1075f22f6b71575b6fc20d4972b3cfd3b23322ca6fd596dcaef167"}, + {"family": "Conso", "weight": "regular", "file": "conso-regular.ttf", "sha256": "a57f9c7961c31bbbab24b3c69e9903b88deeb472bff3984bb0d4457ae3b18dcf"}, + {"family": "Conso", "weight": "bold", "file": "conso-semibold.ttf", "sha256": "cfbe5fd091c4e88ad60fc6f716db5d68d792392f36db88119d50a1039e48ebdd"}, + {"family": "Cascadia Mono", "weight": "regular", "file": "CascadiaMono.ttf", "sha256": "0e141cb99609f6f10ad05313fd1807d5cc9e28658dcbb35ab162e52ff67dc718"}, + {"family": "Consolas", "weight": "regular", "file": "consola.ttf", "sha256": "cf00b507b3286870cc5064ebd0633c303f70b491a4af25eec2d32df413db0179"}, + {"family": "Consolas", "weight": "bold", "file": "consolab.ttf", "sha256": "7e8de70c92a891722bd3fbd623db005caab2bfe267e5dab5be46b95a44e0c7f1"}, + {"family": "DejaVu Sans Mono", "weight": "regular", "file": "DejaVuSansMono.ttf", "sha256": "602ec86b8948cfcd956482fe64f94c36c867770149ef2f791d4613f443bcecb3"}, + {"family": "DejaVu Sans Mono", "weight": "bold", "file": "DejaVuSansMono-Bold.ttf", "sha256": "baada9a5172fe20886251aff0433fc38461912d5daf07287e7bee56620a8da96"} + ] +} diff --git a/tools/repo_card.py b/tools/repo_card.py index 620ac95..e9f219e 100644 --- a/tools/repo_card.py +++ b/tools/repo_card.py @@ -15,14 +15,24 @@ byte count changes with the checkout, so those rows carry the shape of the value instead: how many entries, how many keys. A picture that shows a hash is a picture that is wrong by the next commit. + +Widths come from face-metrics.json, which holds advances measured off the font +files themselves. An earlier version of this module guessed a width from a +character's class, gave every lowercase letter one number, and handed that same +guess to the check meant to catch text running off the page. The two agreed +with each other and four drawings shipped past the rule anyway. The file next +door is the second opinion. It is generated from the faces, and the check reads +it rather than asking this module how wide its own text is. """ from __future__ import annotations +import json +from pathlib import Path + from repo_art import GROTESK, MONO, _esc, _num W = 960 PAD = 44 -ROW_H = 46 TOP = 142 KEY_W = 186 VAL_W = 258 @@ -57,48 +67,80 @@ # something else, so a spec may name its own three and these are the default. HEADS = ("field", "what comes back", "how you check it") -# What one character draws in the note and footnote columns, in pixels at -# 11.5px. These are measured off a rendered probe rather than assumed, because -# a budget counted in characters cannot tell an uppercase line from a -# lowercase one: capitals run about a quarter wider, so a row of verdict -# tokens fits a character count and still draws off the edge of the page. -# -# The weights round up. The drawing ships to readers whose machine resolves a -# different face than the one measured, so a line that stops a little short is -# a smaller defect than one that runs past the rule. -UPPER, LOWER, DIGIT, SPACE, NARROW = 7.1, 5.75, 6.3, 3.2, 2.7 -_NARROW = frozenset(".,;:'!|") - - -def _advance(char: str) -> float: - if char == " ": - return SPACE - if char.isupper(): - return UPPER - if char.islower(): - return LOWER - if char.isdigit(): - return DIGIT - return NARROW if char in _NARROW else LOWER +METRICS_FILE = Path(__file__).with_name("face-metrics.json") +_METRICS = json.loads(METRICS_FILE.read_text(encoding="utf-8")) +FIRST, LAST = _METRICS["range"] + + +def _widest(group: str, weight: str) -> tuple[float, ...]: + """Per character, the widest advance any measured face gives it, in em. + + A reader's machine resolves one face out of the stack and we do not get to + know which. Taking the maximum means the drawing fits whichever one it + lands in, at the cost of a line that sometimes stops short of the rule. + """ + faces = list(_METRICS[group][weight].values()) + return tuple(max(face[i] for face in faces) / 1000.0 + for i in range(LAST - FIRST + 1)) + + +SANS = _widest("sans", "regular") +SANS_BOLD = _widest("sans", "bold") +MONO_REG = _widest("mono", "regular") +MONO_BOLD = _widest("mono", "bold") +_OFF_TABLE = max(max(SANS), max(SANS_BOLD), max(MONO_REG), max(MONO_BOLD)) + +# Every text element on the card: the table that draws it, its size in pixels, +# and its letter-spacing in em. The names follow what the element is rather +# than its CSS class, because two of them override the family on the element +# itself and the class no longer tells you the face. +ROLE = { + "title": (SANS_BOLD, 21.0, 0.0), + "note": (SANS, 11.5, 0.0), + "foot": (SANS, 11.5, 0.0), + "kicker": (MONO_REG, 11.0, 0.16), + "head": (MONO_REG, 11.0, 0.16), + "source": (MONO_REG, 11.5, 0.0), + "key": (MONO_BOLD, 13.0, 0.0), + "value": (MONO_REG, 12.0, 0.0), +} + + +def element_width(text: str, role: str) -> float: + """What one element draws, in pixels, in the widest face it can land in.""" + table, size, tracking = ROLE[role] + total = sum(table[ord(c) - FIRST] if FIRST <= ord(c) <= LAST else _OFF_TABLE + for c in text) + return total * size + tracking * size * len(text) def text_width(text: str) -> float: - """What a line of note or footnote prose draws, in pixels.""" - return sum(_advance(char) for char in text) + """What one line of note or footnote prose draws, in pixels.""" + return element_width(text, "note") -# One line of the note column, in pixels. Two lines fit the row. +# One line of the note column, in pixels, and the lines a row has room for. NOTE_BUDGET = NOTE_W -NOTE_LINES = 2 +NOTE_LINES = 3 # The footnote runs the width of the page at the same size, so it holds more. FOOT_BUDGET = W - PAD * 2 -FOOT_LINES = 3 +FOOT_LINES = 4 + +LEADING = 15 +FOOT_LEADING = 16 +# A row holding two note lines. A third line adds one line of leading to it. +ROW_H = 46 def _wrap(text: str, width: float = NOTE_BUDGET, limit: int = NOTE_LINES) -> list[str]: - """Greedy wrap by drawn width, cut to the lines the caller has room for.""" + """Greedy wrap by drawn width, cut to the lines the caller has room for. + + A word wider than the whole budget is left alone on its line and draws past + the edge. Wrapping cannot fix that, so the check reads the width of every + line it gets back rather than only whether text was cut. + """ lines: list[str] = [] line = "" for word in text.split(): @@ -113,23 +155,29 @@ def _wrap(text: str, width: float = NOTE_BUDGET, return lines[:limit] -def _row_y(index: int) -> float: - return TOP + index * ROW_H +def row_height(fields: list[dict]) -> float: + """How tall every row on this card is: what its longest note needs.""" + lines = max(len(_wrap(field["note"])) for field in fields) + return ROW_H + LEADING * max(0, lines - 2) + +def _row_y(index: int, row_h: float = ROW_H) -> float: + return TOP + index * row_h -def _row(index: int, field: dict) -> str: + +def _row(index: int, field: dict, row_h: float = ROW_H) -> str: """One field: its name, what comes back in it, and how to check it.""" - y = _row_y(index) + y = _row_y(index, row_h) tone = TONE[field.get("tone", "none")] accent = field.get("tone", "none") != "none" notes = "".join( - f'' + f'' f"{_esc(line)}" for i, line in enumerate(_wrap(field["note"]))) rule = (f'') if accent else "" + f'height="{_num(row_h - 8)}" fill="{tone}"/>') if accent else "" return (f'{rule}' + f'width="{W - PAD * 2}" height="{_num(row_h - 8)}" rx="3"/>{rule}' f'{_esc(field["key"])}' f' str: def _footnote(text: str, top: float) -> str: return "".join( - f'{_esc(line)}' + f'' + f"{_esc(line)}" for i, line in enumerate(_wrap(text, FOOT_BUDGET, FOOT_LINES))) def card_svg(spec: dict) -> str: """A receipt drawn field by field, with the source that produced it.""" fields = spec["fields"] + row_h = row_height(fields) foot = _wrap(spec["footnote"], FOOT_BUDGET, FOOT_LINES) - rule = _row_y(len(fields)) + 12 - height = rule + 22 + len(foot) * 16 - rows = "".join(_row(i, f) for i, f in enumerate(fields)) + rule = _row_y(len(fields), row_h) + 12 + height = rule + 22 + len(foot) * FOOT_LEADING + rows = "".join(_row(i, f, row_h) for i, f in enumerate(fields)) return ( f'