Type specimen generator

I made a proofing/specimen generator for the typeface I’m creating. There are many proofers better than mine, like Colophon’s, but I made my own anyways. Why? Well, because I wanted to. I also wanted to make a proofing tool that would double as a quasi case study. I made this as plain-jane as possible from a technological standpoint to keep the focus on the typeface.

This code in particular is pretty rough around the edges, but I’ve decided to share a little earlier in the process. I hope to eventually make improvements to pretty much every part of this project.

Instructions

  1. Copy the each code snippet below into files that on your machine. Follow the same naming and directory structure.
  2. Install Node.js
  3. Edit the following variables to fit your needs:
  1. Make changes to style.css to suit your needs
  2. Fire up a Terminal window and cd to the directory where you saved the type-specimen.js file
  3. Run node type-specimen.js in your terminal
  4. A new index.html file will be output in the same directory you ran the command.
            
type-specimen.js
import fs from 'fs'; import { marked } from 'marked' import { markedSmartypants } from "marked-smartypants"; import path from 'path'; marked.setOptions({ headerIds: false, mangle: false, gfm: true }); marked.use(markedSmartypants()); const STYLE = fs.readFileSync('./style.css', 'utf8'); const DOCUMENT_TEMPLATE = fs.readFileSync('./_templates/document.template.html', 'utf8'); const INTRO_TEMPLATE = fs.readFileSync('./_templates/intro.template.html', 'utf8'); const SPECIMEN_TEMPLATE = fs.readFileSync('./_templates/specimen.template.html', 'utf8'); const WEIGHTS_TEMPLATE = fs.readFileSync('./_templates/weights.template.html', 'utf8'); const VIEWER_TEMPLATE = fs.readFileSync('./_templates/viewer.template.html', 'utf8'); const METRICS_TEMPLATE = fs.readFileSync('./_templates/metrics.template.html', 'utf8'); const SAMPLES_TEMPLATE = fs.readFileSync('./_templates/samples.template.html', 'utf8'); const CHARACTERS_TEMPLATE = fs.readFileSync('./_templates/characters.template.html', 'utf8'); const LETTERS = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'] const NUMBERS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'] const CHARACTERS = ['!', '@', '#', '&', '*', '?', '’', '“', '”'] const TITLE = 'Olivia Sans' const SUB_TITLE = 'Olivia Sans is a simple geometric sans serif typeface. But that’s probably obvious.' const DESCRIPTION = 'The world doesn’t need another typeface, but I wanted to make one anyways. Olivia Sans was made for my own design projects my daughters’ ones. The typeface consists of 126 glyphs and a weight range of regular to bold. It’s not much, but that’s all that was needed. The design aims for strong continuity across each glyph with consistent angles and thicks/thins. Given this was my first ever typeface I made, I decided to <a href="https://pjonori.blog/posts/making-a-typeface/">journal my process</a>.' const METRICS = {ascender: 800, capHeight: 700, xHeight: 515, baseline: 0, descender: -200} const METRICS_SAMPLE = 'Rig' const SAMPLES = [ 'The way you do anything is the way you do everything.', '_Purveyor of fine typos._', '‹ Back', '79°', 'A system is a system because its connections work together. As a system grows, it becomes increasingly difficult to understand how everything works—or if it works at all. This rears its head in general unintelligibility, brittleness and unpredictability. At which point the system has officially devolved into a pile of stuff.\n\nUnpredictability in particular is scary. Because unpredictability, is well, unpredictable. Unpredictable can result in good, bad, or horrible. It can be a combination of the three one minute, then none of the above the next. Which is chaos. And chaos is bad.', '3.14159', 'The fairy game is fun, but it’s almost done. \nWe go to the mall, but don’t get a doll. \nWe got taught, it was quite a lot. \nThe four of us walked through the door. \nOn that first day and we all said, “hooray”!\n\n&mdash;_Fairy Game_, a poem by Olivia' ] const WEIGHT_RANGE = {min: 400, max: 700} let letters = '' for(let i = 0; i < LETTERS.length; i++) { letters += '<div class="glyph">' + LETTERS[i].toUpperCase() + '</div>' letters += '<div class="glyph">' + LETTERS[i].toLowerCase() + '</div>' } let letterPairings = '' for(let i = 0; i < LETTERS.length; i++) { letterPairings += '<div class="glyph">' + LETTERS[i].toUpperCase() + '' + LETTERS[i].toLowerCase() + '</div>' } let numbers = '' for(let i = 0; i < NUMBERS.length; i++) { numbers += '<div class="glyph">' + NUMBERS[i] + '</div>' } let characters = '' for(let i = 0; i < CHARACTERS.length; i++) { characters += '<div class="glyph">' + CHARACTERS[i] + '</div>' } let glyphs = letters.concat(numbers).concat(characters); let intro = INTRO_TEMPLATE.replace(new RegExp('%title%', 'g'), TITLE); intro = intro.replace(new RegExp('%sub-title%', 'g'), SUB_TITLE); intro = intro.replace(new RegExp('%description%', 'g'), DESCRIPTION); let specimen = SPECIMEN_TEMPLATE.replace(new RegExp('%specimen-letters%', 'g'), letterPairings); specimen = specimen.replace(new RegExp('%specimen-numbers%', 'g'), numbers); specimen = specimen.replace(new RegExp('%specimen-characters%', 'g'), characters); let weights = WEIGHTS_TEMPLATE.replace(new RegExp('%weights-min%', 'g'), WEIGHT_RANGE.min); weights = weights.replace(new RegExp('%weights-max%', 'g'), WEIGHT_RANGE.max); weights = weights.replace(new RegExp('%weights-glyphs%', 'g'), letters); let viewer = VIEWER_TEMPLATE.replace(new RegExp('%viewer-glyphs%', 'g'), glyphs); let metrics = METRICS_TEMPLATE.replace(new RegExp('%cap-height%', 'g'), METRICS.capHeight); metrics = metrics.replace(new RegExp('%x-height%', 'g'), METRICS.xHeight); metrics = metrics.replace(new RegExp('%baseline%', 'g'), METRICS.baseline); metrics = metrics.replace(new RegExp('%descender%', 'g'), METRICS.descender); metrics = metrics.replace(new RegExp('%metrics-sample%', 'g'), METRICS_SAMPLE); let sampleMarkup = ''; for(let i = 0; i < SAMPLES.length; i++) { sampleMarkup+='<div class="sample ' + 'sample-' + Number(i+1) + '">' + marked(SAMPLES[i]) + '</div>' } let samples = SAMPLES_TEMPLATE.replace(new RegExp('%samples%', 'g'), sampleMarkup); let document = DOCUMENT_TEMPLATE.replace(new RegExp('%title%', 'g'), TITLE); document = document.replace(new RegExp('%style%', 'g'), '<style>' + STYLE + '</style>'); document = document.replace(new RegExp('%intro%', 'g'), intro); document = document.replace(new RegExp('%specimen%', 'g'), specimen); document = document.replace(new RegExp('%weights%', 'g'), weights); document = document.replace(new RegExp('%viewer%', 'g'), viewer); document = document.replace(new RegExp('%metrics%', 'g'), metrics); document = document.replace(new RegExp('%samples%', 'g'), samples); document = document.replace(new RegExp('%characters%', 'g'), CHARACTERS_TEMPLATE); fs.writeFileSync('index.html', document);
_templates/intro.template.html
<section class="hero"> <h1>%title%</h1> <h2>%sub-title%</h2> <p class="columns">%description%</p> </section>
_templates/specimen.template.html
<section class="specimen-container"> <div class="specimen specimen-regular"> <div class="specimen-group"> %specimen-letters% </div> <div class="specimen-group"> %specimen-numbers% </div> <div class="specimen-group"> %specimen-characters% </div> </div> <div class="specimen specimen-bold"> <div class="specimen-group"> %specimen-letters% </div> <div class="specimen-group"> %specimen-numbers% </div> <div class="specimen-group"> %specimen-characters% </div> </div> </section>
_templates/weights.template.html
<section class="variable"> <div class="caption">Font weights: %weights-min%&mdash;%weights-max%</div> <div class="letters"> %weights-glyphs% </div> </section>
_templates/viewer.template.html
<section class="letters-container" style="padding: 5rem 0;"> <div class="scroll">Scroll →</div> <div class="letter-display"> %viewer-glyphs% </div> </section>
_templates/metrics.template.html
<section> <div class="metrics"> <div class='metric cap-height'><span>%cap-height%</span></div> <div class='metric x-height'><span>%x-height%</span></div> <div class='metric baseline'><span>%baseline%</span></div> <div class='metric descender'><span>%descender%</span></div> <div class="text">%metrics-sample%</div> </div> </section>
_templates/samples.template.html
<section class="samples-container"> %samples% </section>
_templates/characters.template.html
<section class="special-characters"> <div class="character-chevron">›</div><div class="character-arrow">↑</div><div class="character-infinity">∞</div><div class="character-ampersand">&</div> </section>
style.css
:root { --font-size-3000: clamp(150px, 42vw, 640px); --font-size-2000: clamp(64px, 31vw, 480px); --font-size-1000: clamp(36px, 15vw, 245px); --font-size-900: clamp(32px, 8vw, 112px); --font-size-750: clamp(24px, 4vw, 64px); --font-size-500: clamp(24px, 4vw, 48px); --font-size-300: clamp(20px, 3vw, 32px); --font-size-250: clamp(14px, 1.2vw, 18px); --font-size-100: clamp(13px, 1.1vw, 16px); --padding: clamp(16px, 5vw, 80px); --padding-loose: clamp(32px, 8vw, 160px); --color-positive-300: #fafafa; --color-positive-200: #ccc; --color-positive-100: #222; --color-positive-0: #000; --color-negative: #fff; --color-dark: #000; --color-blue: #2070BB; --color-red: #EE450F; --color-yellow: #FED915; --color-purple: #CC72F6; } @media (prefers-color-scheme: dark) { :root { --color-positive-300: #333; --color-positive-200: #ccc; --color-positive-100: #efefef; --color-positive-0: #fff; --color-negative: #222; --color-dark: #000; --color-blue: #81c2ff; --color-red: #EE450F; --color-yellow: #FED915; --color-purple: #8304be; } } @font-face { font-family: "Olivia Sans"; src: url("OliviaSans-VariableVF.ttf") format("truetype-variations"); font-weight: 400 700; } @keyframes FRA{ 0%{ font-weight: 400; color: var(--color-blue); } 50%{ font-weight: 700; color: var(--color-red); } 100%{ font-weight: 400; color: var(--color-blue); } } html { overflow-x: hidden; } body, html { padding: 0; margin: 0; font-size: 16px; -webkit-font-smoothing: antialiased; } body { font-family: 'Olivia Sans'; padding: var(--padding); margin: 0 auto; line-height: 1.5; font-synthesis: none; /*font-variation-settings: "ital" 9;*/ overflow-anchor: none; background: var(--color-negative); color: var(--color-positive-0); max-width: none; padding-top: 0; position: static; } header { max-width: 1000px; margin: 0 auto; position: relative; } a { color: inherit; } em { font-variation-settings: "ital" 10; } section.hero { margin-top: 10rem; } section { padding: var(--padding); box-sizing: border-box; max-width: 1400px; margin-left: auto; margin-right: auto; } header + section, section + section { margin-top: var(--padding-loose); } h1 { font-size: var(--font-size-1000); letter-spacing: -0.025em; line-height: .9; text-indent: -.07em; font-weight: 600; margin: 0; } h2 { max-width: 40ch; font-weight: 400; font-size: var(--font-size-500); letter-spacing: -0.01em; line-height: 1.25; margin-top: 0.25em; } .caption { font-size: var(--font-size-100); font-weight: 700; } .letters { box-sizing: border-box; display: grid; grid-template-columns: repeat(auto-fit, minmax(4ch, 1fr)); /* this works in safari and chrome */ gap: 0em; font-size: 2em; } .letters .glyph { display: flex; /*height: 4ch;*/ width: 100%; justify-content: center; align-items: center; border: 1px solid var(--color-positive-300); background: var(--color-negative); margin-top: -1px; user-select: none; aspect-ratio: 1; } .columns { font-weight: 500; columns: 40ch auto; column-gap: 2em; font-size: var(--font-size-250); width: 80%; min-width: 320px; } .specimen-container { background: var(--color-positive-100); display: flex; flex-direction: column; gap: 2em; font-size: var(--font-size-750); } .specimen { display: flex; flex-direction: column; gap: .5em; } .specimen-group { display: flex; flex-wrap: wrap; color: var(--color-positive-300); line-height: 1.1; gap: .15em; } .specimen-bold { font-weight: 700; } .variable { display: flex; flex-direction: column; padding-left: 0; padding-right: 0; gap: 1rem; } .variable .letters { font-size: var(--font-size-500); grid-template-columns: repeat(auto-fit, minmax(4ch, 1fr)); color: var(--color-blue); } .variable .glyph { animation: FRA forwards 6s infinite; animation-timing-function: ease-in-out; } .variable .glyph:nth-child(2) { animation-delay: 30ms; }.variable .glyph:nth-child(3) { animation-delay: 60ms; }.variable .glyph:nth-child(4) { animation-delay: 90ms; }.variable .glyph:nth-child(5) { animation-delay: 120ms; }.variable .glyph:nth-child(6) { animation-delay: 150ms; }.variable .glyph:nth-child(7) { animation-delay: 180ms; }.variable .glyph:nth-child(8) { animation-delay: 210ms; }.variable .glyph:nth-child(9) { animation-delay: 240ms; }.variable .glyph:nth-child(10) { animation-delay: 270ms; }.variable .glyph:nth-child(11) { animation-delay: 300ms; }.variable .glyph:nth-child(12) { animation-delay: 330ms; }.variable .glyph:nth-child(13) { animation-delay: 360ms; }.variable .glyph:nth-child(14) { animation-delay: 390ms; }.variable .glyph:nth-child(15) { animation-delay: 420ms; }.variable .glyph:nth-child(16) { animation-delay: 450ms; }.variable .glyph:nth-child(17) { animation-delay: 480ms; }.variable .glyph:nth-child(18) { animation-delay: 510ms; }.variable .glyph:nth-child(19) { animation-delay: 540ms; }.variable .glyph:nth-child(20) { animation-delay: 570ms; }.variable .glyph:nth-child(21) { animation-delay: 600ms; }.variable .glyph:nth-child(22) { animation-delay: 630ms; }.variable .glyph:nth-child(23) { animation-delay: 660ms; }.variable .glyph:nth-child(24) { animation-delay: 690ms; }.variable .glyph:nth-child(25) { animation-delay: 720ms; }.variable .glyph:nth-child(26) { animation-delay: 750ms; }.variable .glyph:nth-child(27) { animation-delay: 780ms; }.variable .glyph:nth-child(28) { animation-delay: 810ms; }.variable .glyph:nth-child(29) { animation-delay: 840ms; }.variable .glyph:nth-child(30) { animation-delay: 870ms; }.variable .glyph:nth-child(31) { animation-delay: 900ms; }.variable .glyph:nth-child(32) { animation-delay: 930ms; }.variable .glyph:nth-child(33) { animation-delay: 960ms; }.variable .glyph:nth-child(34) { animation-delay: 990ms; }.variable .glyph:nth-child(35) { animation-delay: 1020ms; }.variable .glyph:nth-child(36) { animation-delay: 1050ms; }.variable .glyph:nth-child(37) { animation-delay: 1080ms; }.variable .glyph:nth-child(38) { animation-delay: 1110ms; }.variable .glyph:nth-child(39) { animation-delay: 1140ms; }.variable .glyph:nth-child(40) { animation-delay: 1170ms; }.variable .glyph:nth-child(41) { animation-delay: 1200ms; }.variable .glyph:nth-child(42) { animation-delay: 1230ms; }.variable .glyph:nth-child(43) { animation-delay: 1260ms; }.variable .glyph:nth-child(44) { animation-delay: 1290ms; }.variable .glyph:nth-child(45) { animation-delay: 1320ms; }.variable .glyph:nth-child(46) { animation-delay: 1350ms; }.variable .glyph:nth-child(47) { animation-delay: 1380ms; }.variable .glyph:nth-child(48) { animation-delay: 1410ms; }.variable .glyph:nth-child(49) { animation-delay: 1440ms; }.variable .glyph:nth-child(50) { animation-delay: 1470ms; }.variable .glyph:nth-child(51) { animation-delay: 1500ms; }.variable .glyph:nth-child(52) { animation-delay: 1530ms; }.variable .glyph:nth-child(53) { animation-delay: 1560ms; }.variable .glyph:nth-child(54) { animation-delay: 1590ms; }.variable .glyph:nth-child(55) { animation-delay: 1620ms; }.variable .glyph:nth-child(56) { animation-delay: 1650ms; }.variable .glyph:nth-child(57) { animation-delay: 1680ms; }.variable .glyph:nth-child(58) { animation-delay: 1710ms; }.variable .glyph:nth-child(59) { animation-delay: 1740ms; }.variable .glyph:nth-child(60) { animation-delay: 1770ms; }.variable .glyph:nth-child(61) { animation-delay: 1800ms; }.variable .glyph:nth-child(62) { animation-delay: 1830ms; }.variable .glyph:nth-child(63) { animation-delay: 1860ms; }.variable .glyph:nth-child(64) { animation-delay: 1890ms; }.variable .glyph:nth-child(65) { animation-delay: 1920ms; }.variable .glyph:nth-child(66) { animation-delay: 1950ms; } .samples-container { padding: 5em 0; display: flex; flex-wrap: wrap; } .sample { padding: var(--padding-loose) var(--padding); box-sizing: border-box; display: flex; justify-content: center; align-items: center; flex-grow: 1; flex-direction: column; } .sample > * { max-width: 100%; } .sample p { margin: 0; } .sample p + p { margin-top: 1em; } .sample-1, .sample-7 { width: 100%; min-width: 320px; } .sample-2, .sample-5, .sample-6 { width: 50%; min-width: 320px; } .sample-3, .sample-4 { width: 25%; min-width: 160px; } .sample-1 { background: var(--color-yellow); color: var(--color-dark); } .sample-1 p { width: 20ch; font-size: var(--font-size-750); font-weight: 400; } .sample-2 { background: var(--color-red); font-size: var(--font-size-300); color: var(--color-negative); } .sample-3 { font-size: var(--font-size-250); font-weight: 500; } .sample-4 { background: var(--color-blue); font-size: var(--font-size-900); color: var(--color-negative); } .sample-5 { background: var(--color-positive-100); } .sample-5 p { width: 40ch; font-size: var(--font-size-250); color: var(--color-positive-300); font-weight: 500; line-height: 1.5; max-width: 100%; } .sample-6 { background: var(--color-positive-300); font-size: var(--font-size-900); font-weight: 700; } .sample-7 { font-size: var(--font-size-300); font-weight: 600; background: var(--color-purple); } .sample-7 p + p { display: block; font-size: var(--font-size-100); font-weight: 600; } .metrics { font-size: var(--font-size-2000); line-height: 2.2ex; position: relative; text-align: center; } .metric { position: absolute; width: 100%; border-bottom: 1px solid var(--color-positive-200); z-index: 0; font-weight: 700; text-align: right; } .metric span { font-size: 10px; line-height: 1.5; display: block; bottom: 0; right: 0; position: absolute; } .text { position: relative; z-index: 2; } .cap-height { bottom: 1.74ex; height: .195ex; } .x-height { bottom: 1.38ex; height: .359ex; } .baseline { bottom: .384ex; height: 1ex; } .descender { height: .388ex; bottom: 0; } .letters-container { position: relative; display: flex; justify-content: end; } .letters-container .letters { width: 50%; } .letter-display { width: 100%; position: relative; display: flex; overflow-x: auto; overflow-y: visible; gap: 32px; flex-grow: 1; background: var(--color-positive-300); min-height: 500px; } .scroll { position: absolute; font-size: var(--font-size-100); width: 100%; z-index: 4; font-weight: 700; top: 6rem; left: 1rem; } .letter-display .glyph { min-width: 100%; display: flex; justify-content: center; align-items: center; font-size: var(--font-size-3000); line-height: 1.25; } .letters-container .glyph { color: var(--color-positive-0); text-decoration: none; } .special-characters { font-size: var(--font-size-2000); font-weight: 550; display: flex; flex-wrap: wrap; } .special-characters div { flex-basis: 50%; aspect-ratio: 4; display: flex; justify-content: center; align-items: center; line-height: 1; } .character-chevron { color: var(--color-yellow); } .character-arrow { color: var(--color-positive-100); } .character-infinity { color: var(--color-blue); } .character-ampersand { color: var(--color-red); } .end { font-size: var(--font-size-250); display: flex; justify-content: center; align-items: center; } .end a { display: flex; flex-direction: column; justify-content: center; align-items: center; gap: .15em; font-weight: 700; text-decoration: none; }