Over semfont
semfont is een kleine bibliotheek die typografie automatisch aanpast om de leesbaarheid van tekst te verhogen. Het systeem analyseert tekst op vier verschillende assen:
- Valentie: Meet of een woord positief of negatief is, wat wordt vertaald naar kleur.
- Salience: Bepaalt de opvallendheid van een woord op basis van zeldzaamheid en frequentie, wat leidt tot vette letters.
- Verrassing: Detecteert wendingen in de zin via contrastwoorden of zeldzaamheid, wat resulteert in highlights.
- Zekerheid: Analyseert de stelligheid van de schrijver, wat wordt weergegeven in cursief.
Verbeteringen en Implementatie
Om blinde vlekken bij eenvoudige woordanalyses te voorkomen, voert de bibliotheek een tweede pass uit. Hierbij worden clausules als geheel geanalyseerd, waardoor sarcasme, complexe ontkenningen en context (zoals het verbeteren van een 'crash') correct worden herkend.
De bibliotheek is breed inzetbaar: hij kan worden gebruikt als React-component, direct in JavaScript via een analyze-functie, of in de browser via import maps. Een specifieke toepassing is het verbeteren van de leesbaarheid van streaming tekst uit AI-systemen.
Een lettertype dat leest wat je hebt geschreven — Rohan Adwankar
Hoe het werkt
Elk woord krijgt vier scores. Elke score begint als een opzoeking in een woordenlijst en wordt vervolgens aangepast op basis van een aantal regels over de woorden eromheen.
Valentie (Valence)
Valentie geeft aan hoe goed of slecht een woord is, op een schaal van -1 tot 1. Een ontkenning (negator) tot drie woorden terug draait het teken om en dempt de waarde, omdat "niet geweldig" eerder een milde klacht is dan het spiegelbeeld van lof. Een versterker (intensifier) tot twee woorden terug schaalt de waarde in plaats daarvan.
let v = VALENCE[word] ?? 0; // geweldig -> 0.75
if (negatorWithin(3)) v = -v * 0.74; // niet geweldig -> -0.55
v *= gain; // echt geweldig -> 0.98
Salience (Opvallendheid)
Salience geeft aan in hoeverre een woord de moeite waard is om naar te kijken, van 0 tot 1. Een frequentielijst geeft elk woord een zeldzaamheidswaarde: 0 voor een van de honderd meest voorkomende Engelse woorden en 1 voor een woord dat nog nooit is gezien. Zeldzaamheid alleen is niet genoeg; de score stijgt ook naarmate het woord vaker voorkomt in deze specifieke tekst. Een ongebruikelijk woord dat je herhaaldelijk gebruikt, is namelijk vaak waar de tekst over gaat.
const seen = Math.min(1, (repeats - 1) / 2);
const repetition = 0.45 + 0.55 * seen;
let s = SALIENCE[word] ?? 0;
s = Math.max(s, 0.55 * rarity * repetition);
// zeldzaamheid('the') 0.00, zeldzaamheid('kubelet') 0.93
// kubelet één keer genoemd -> 0.23
// kubelet 3 keer genoemd -> 0.51
Verrassing (Surprise)
Verrassing geeft aan waar de zin een wending neemt, van 0 tot 1. Sommige woorden kondigen dit zelfstandig aan, zoals "plotseling" of "ironisch". Anders komt het voort uit de positie: alles tot zes woorden na een contrastwoord krijgt deze score, afnemend met de afstand. Dit geldt ook voor elk woord dat veel zeldzamer is dan de rest van de passage.
let s = SURPRISE[word] ?? 0; // plotseling -> 0.85
if (afterContrast) {
s = Math.max(s, 0.45 * 0.82 ** (distance - 1));
}
s += 0.3 * Math.max(0, rarity - passageMeanRarity - 0.25);
// 'The tests failed' -> failed 0.16
// 'It compiled, but the tests failed' -> failed 0.44
Zekerheid (Certainty)
Zekerheid geeft aan hoe stellig de schrijver klinkt, van -1 (nuancerend/twijfelend) tot 1 (stellig). Woorden zoals "waarschijnlijk" en "definitief" staan in een tabel. Maar als je schrijft "The build probably failed", ben je niet onzeker over het woord "waarschijnlijk", maar over het feit of het is mislukt. Daarom behoudt de nuancering zijn eigen score, en krijgt elk ander woord in de zin 55% daarvan, waardoor de hele regel enigszins mee helt in plaats van slechts één woord in het midden.
c = CERTAINTY[word] ?? sentenceCertainty * 0.55;
// 'The build probably failed.'
// probably -0.40, elk ander woord -0.22
Vervolgens koppelt een thema elke score aan een typografische as:
- Valentie → kleur
- Salience → gewicht (bold)
- Verrassing → highlight
- Zekerheid → helling (cursief)
Elke as heeft een drempelwaarde, waardoor de meeste woorden ongewijzigd blijven.
Het algoritme verbeteren
De bovengenoemde regels kijken slechts enkele woorden naar links en rechts, en dat venster heeft een blinde vlek. Schrijf je: "I would not go so far as to call the new editor great", dan blijft "great" groen, omdat de "not" die het moet annuleren negen woorden terug staat. Schrijf je: "We fixed the crash", dan krijg je één groen woord en één rood woord, omdat er niets is dat "fixed" verbindt met het object dat is gerepareerd.
Daarom wordt er nu een tweede pass uitgevoerd na de vensterregels, waarbij elke clausule als een geheel wordt gelezen. Een ontkenning reikt nu tot het einde van zijn clausule en vervaagt met de afstand, wat "great" rood maakt. Een werkwoord als "fixed", "recovered" of "avoided" markeert alles wat volgt als het ding dat verbeterd is, waardoor "crash" groen wordt. In dezezelfde pass worden formuleringen als "less broken" en "fewer complaints" herkend als verbeteringen, "too simple" als een klacht, en een enkelvoudig "Great" vóór slecht nieuws als sarcasme.
Elke wijziging wordt geregistreerd op het woord, zodat je kunt vragen waarom een woord een bepaalde kleur heeft gekregen:
analyze('We fixed the crash.').tokens[6];
// { text: 'crash', valence: 0.44,
// notes: ['resolved by "fixed"'] }
Deze tweede pass kost ongeveer evenveel als de eerste en blijft binnen het budget, dus er is geen schakelaar nodig om dit aan of uit te zetten. Hieronder staan tien zinnen die tot deze verbetering hebben geleid. Links is de eerste versie, rechts de huidige:
| Voorheen | Nu |
| Great, another outage. Just what I needed today. | Great, another outage. Just what I needed today. |
| We fixed the crash and closed the security hole before anyone noticed. | We fixed the crash and closed the security hole before anyone noticed. |
| The cluster recovered from the crash in under a minute. | The cluster recovered from the crash in under a minute. |
| We avoided a catastrophic outage by catching the bug in staging. | We avoided a catastrophic outage by catching the bug in staging. |
| I would not go so far as to call the new editor great. | I would not go so far as to call the new editor great. |
| Less broken than last week, and far fewer complaints. | Less broken than last week, and far fewer complaints. |
| The memory leak is gone. | The memory leak is gone. |
| The reviewer called it "terrible", which is wrong. | The reviewer called it "terrible", which is wrong. |
| The API is too simple and the docs are too clever. | The API is too simple and the docs are too clever. |
Het gebruiken
In React
import { SemanticText } from 'semfont';
<SemanticText as="p">
The migration ran clean on staging. In production it deleted the index,
and the rollback failed too.
</SemanticText>
Kies een thema, of selecteer alleen de kanalen die je wilt:
<SemanticText text={incident} theme="monochrome" />
<SemanticText text={incident} channels={['valence']} />
Leer het systeem je eigen vocabulaire aan:
<SemanticText
lexicon={{ valence: { flaky: -0.7, oncall: -0.4 }, salience: { rollback: 0.8 } }}
text={incident}
/>
Toepassing bij AI-streaming
AI-systemen streamen vaak grote hoeveelheden tekst, wat lastig kan zijn om te lezen. De bedoeling is dat deze bibliotheek eenvoudig in streaming-componenten kan worden geplaatst om de tekst leesbaarder te maken:
import { useChat } from '@ai-sdk/react';
import { SemanticText } from 'semfont';
function Chat() {
const { messages } = useChat();
return messages.map((m) => {
const text = m.parts.filter((p) => p.type === 'text').map((p) => p.text).join('');
return m.role === 'assistant'
? <SemanticText key={m.id} as="p" text={text} />
: <p key={m.id}>{text}</p>;
});
}
Gebruik zonder React
Je kunt ook de scores direct ophalen. analyze is de engine zelf: vier getallen per woord, zonder CSS. Deze imports werken zonder dat React geïnstalleerd is:
import { analyze } from 'semfont/analyze';
import { styleFor, themes } from 'semfont/theme';
const { tokens } = analyze('The rollback failed too.');
tokens[4]; // { text: 'failed', valence: -0.7, salience: 0.23, surprise: 0.06, certainty: 0, ... }
styleFor(tokens[4], themes.editorial).style; // { color: 'color-mix(in oklab, currentColor, oklch(0.58 0.19 25) 53%)' }
De laatste methode is hoe deze pagina zelf werkt. Er is geen bundler gebruikt; een import map vertelt de browser waar semfont/analyze en semfont/theme zich bevinden (gepinned op een versie op npm). De demo-box op de pagina gebruikt dezelfde drie stappen als het React-component: analyze, style, render.
<script type="importmap">
{ "imports": {
"semfont/analyze": "https://cdn.jsdelivr.net/npm/semfont@0.2.0/src/analyze.js",
"semfont/theme": "https://cdn.jsdelivr.net/npm/semfont@0.2.0/src/theme.js"
} }
</script>
<script type="module">
import { analyze } from 'semfont/analyze';
import { styleFor, themes } from 'semfont/theme';
</script>
Volgende stappen
Zoals je uit de implementatie kunt afleiden, is het nagenoeg onmogelijk om een perfecte classificatie te krijgen terwijl de snelheid hoog genoeg blijft om streaming niet te vertragen. Echter, voor het doel van het leesbaarder maken van tekst hoeft het niet perfect te zijn; eenvoudige heuristieken kunnen ons al ver genoeg brengen.
Toch zijn er zaken zoals sarcasme die interessant zouden zijn om via heuristieken aan te pakken, en gevallen van ontkenning met ingebedde clausules die mogelijk te parsen zijn. Daarnaast zouden voor streaming coding agents technische woorden waardevol kunnen zijn om aan het vocabulaire toe te voegen.
Code en demo beschikbaar op: github.com/RohanAdwankar/semfont
Een lettertype dat leest wat je hebt geschreven — Rohan Adwankar
Hoe het werkt
Elk woord krijgt vier scores. Elke score begint als een opzoeking in een woordenlijst en wordt vervolgens aangepast op basis van een aantal regels over de woorden eromheen.
Valentie (Valence)
Valentie geeft aan hoe goed of slecht een woord is, op een schaal van -1 tot 1. Een ontkenning (negator) tot drie woorden terug draait het teken om en dempt de waarde, omdat "niet geweldig" eerder een milde klacht is dan het spiegelbeeld van lof. Een versterker (intensifier) tot twee woorden terug schaalt de waarde in plaats daarvan.
let v = VALENCE[word] ?? 0; // geweldig -> 0.75
if (negatorWithin(3)) v = -v * 0.74; // niet geweldig -> -0.55
v *= gain; // echt geweldig -> 0.98
Salience (Opvallendheid)
Salience geeft aan in hoeverre een woord de moeite waard is om naar te kijken, van 0 tot 1. Een frequentielijst geeft elk woord een zeldzaamheidswaarde: 0 voor een van de honderd meest voorkomende Engelse woorden en 1 voor een woord dat nog nooit is gezien. Zeldzaamheid alleen is niet genoeg; de score stijgt ook naarmate het woord vaker voorkomt in deze specifieke tekst. Een ongebruikelijk woord dat je herhaaldelijk gebruikt, is namelijk vaak waar de tekst over gaat.
const seen = Math.min(1, (repeats - 1) / 2);
const repetition = 0.45 + 0.55 * seen;
let s = SALIENCE[word] ?? 0;
s = Math.max(s, 0.55 * rarity * repetition);
// zeldzaamheid('the') 0.00, zeldzaamheid('kubelet') 0.93
// kubelet één keer genoemd -> 0.23
// kubelet 3 keer genoemd -> 0.51
Verrassing (Surprise)
Verrassing geeft aan waar de zin een wending neemt, van 0 tot 1. Sommige woorden kondigen dit zelfstandig aan, zoals "plotseling" of "ironisch". Anders komt het voort uit de positie: alles tot zes woorden na een contrastwoord krijgt deze score, afnemend met de afstand. Dit geldt ook voor elk woord dat veel zeldzamer is dan de rest van de passage.
let s = SURPRISE[word] ?? 0; // plotseling -> 0.85
if (afterContrast) {
s = Math.max(s, 0.45 * 0.82 ** (distance - 1));
}
s += 0.3 * Math.max(0, rarity - passageMeanRarity - 0.25);
// 'The tests failed' -> failed 0.16
// 'It compiled, but the tests failed' -> failed 0.44
Zekerheid (Certainty)
Zekerheid geeft aan hoe stellig de schrijver klinkt, van -1 (nuancerend/twijfelend) tot 1 (stellig). Woorden zoals "waarschijnlijk" en "definitief" staan in een tabel. Maar als je schrijft "The build probably failed", ben je niet onzeker over het woord "waarschijnlijk", maar over het feit of het is mislukt. Daarom behoudt de nuancering zijn eigen score, en krijgt elk ander woord in de zin 55% daarvan, waardoor de hele regel enigszins mee helt in plaats van slechts één woord in het midden.
c = CERTAINTY[word] ?? sentenceCertainty * 0.55;
// 'The build probably failed.'
// probably -0.40, elk ander woord -0.22
Vervolgens koppelt een thema elke score aan een typografische as:
- Valentie → kleur
- Salience → gewicht (bold)
- Verrassing → highlight
- Zekerheid → helling (cursief)
Elke as heeft een drempelwaarde, waardoor de meeste woorden ongewijzigd blijven.
Het algoritme verbeteren
De bovengenoemde regels kijken slechts enkele woorden naar links en rechts, en dat venster heeft een blinde vlek. Schrijf je: "I would not go so far as to call the new editor great", dan blijft "great" groen, omdat de "not" die het moet annuleren negen woorden terug staat. Schrijf je: "We fixed the crash", dan krijg je één groen woord en één rood woord, omdat er niets is dat "fixed" verbindt met het object dat is gerepareerd.
Daarom wordt er nu een tweede pass uitgevoerd na de vensterregels, waarbij elke clausule als een geheel wordt gelezen. Een ontkenning reikt nu tot het einde van zijn clausule en vervaagt met de afstand, wat "great" rood maakt. Een werkwoord als "fixed", "recovered" of "avoided" markeert alles wat volgt als het ding dat verbeterd is, waardoor "crash" groen wordt. In dezezelfde pass worden formuleringen als "less broken" en "fewer complaints" herkend als verbeteringen, "too simple" als een klacht, en een enkelvoudig "Great" vóór slecht nieuws als sarcasme.
Elke wijziging wordt geregistreerd op het woord, zodat je kunt vragen waarom een woord een bepaalde kleur heeft gekregen:
analyze('We fixed the crash.').tokens[6];
// { text: 'crash', valence: 0.44,
// notes: ['resolved by "fixed"'] }
Deze tweede pass kost ongeveer evenveel als de eerste en blijft binnen het budget, dus er is geen schakelaar nodig om dit aan of uit te zetten. Hieronder staan tien zinnen die tot deze verbetering hebben geleid. Links is de eerste versie, rechts de huidige:
| Voorheen | Nu |
| Great, another outage. Just what I needed today. | Great, another outage. Just what I needed today. |
| We fixed the crash and closed the security hole before anyone noticed. | We fixed the crash and closed the security hole before anyone noticed. |
| The cluster recovered from the crash in under a minute. | The cluster recovered from the crash in under a minute. |
| We avoided a catastrophic outage by catching the bug in staging. | We avoided a catastrophic outage by catching the bug in staging. |
| I would not go so far as to call the new editor great. | I would not go so far as to call the new editor great. |
| Less broken than last week, and far fewer complaints. | Less broken than last week, and far fewer complaints. |
| The memory leak is gone. | The memory leak is gone. |
| The reviewer called it "terrible", which is wrong. | The reviewer called it "terrible", which is wrong. |
| The API is too simple and the docs are too clever. | The API is too simple and the docs are too clever. |
Het gebruiken
In React
import { SemanticText } from 'semfont';
<SemanticText as="p">
The migration ran clean on staging. In production it deleted the index,
and the rollback failed too.
</SemanticText>
Kies een thema, of selecteer alleen de kanalen die je wilt:
<SemanticText text={incident} theme="monochrome" />
<SemanticText text={incident} channels={['valence']} />
Leer het systeem je eigen vocabulaire aan:
<SemanticText
lexicon={{ valence: { flaky: -0.7, oncall: -0.4 }, salience: { rollback: 0.8 } }}
text={incident}
/>
Toepassing bij AI-streaming
AI-systemen streamen vaak grote hoeveelheden tekst, wat lastig kan zijn om te lezen. De bedoeling is dat deze bibliotheek eenvoudig in streaming-componenten kan worden geplaatst om de tekst leesbaarder te maken:
import { useChat } from '@ai-sdk/react';
import { SemanticText } from 'semfont';
function Chat() {
const { messages } = useChat();
return messages.map((m) => {
const text = m.parts.filter((p) => p.type === 'text').map((p) => p.text).join('');
return m.role === 'assistant'
? <SemanticText key={m.id} as="p" text={text} />
: <p key={m.id}>{text}</p>;
});
}
Gebruik zonder React
Je kunt ook de scores direct ophalen. analyze is de engine zelf: vier getallen per woord, zonder CSS. Deze imports werken zonder dat React geïnstalleerd is:
import { analyze } from 'semfont/analyze';
import { styleFor, themes } from 'semfont/theme';
const { tokens } = analyze('The rollback failed too.');
tokens[4]; // { text: 'failed', valence: -0.7, salience: 0.23, surprise: 0.06, certainty: 0, ... }
styleFor(tokens[4], themes.editorial).style; // { color: 'color-mix(in oklab, currentColor, oklch(0.58 0.19 25) 53%)' }
De laatste methode is hoe deze pagina zelf werkt. Er is geen bundler gebruikt; een import map vertelt de browser waar semfont/analyze en semfont/theme zich bevinden (gepinned op een versie op npm). De demo-box op de pagina gebruikt dezelfde drie stappen als het React-component: analyze, style, render.
<script type="importmap">
{ "imports": {
"semfont/analyze": "https://cdn.jsdelivr.net/npm/semfont@0.2.0/src/analyze.js",
"semfont/theme": "https://cdn.jsdelivr.net/npm/semfont@0.2.0/src/theme.js"
} }
</script>
<script type="module">
import { analyze } from 'semfont/analyze';
import { styleFor, themes } from 'semfont/theme';
</script>
Volgende stappen
Zoals je uit de implementatie kunt afleiden, is het nagenoeg onmogelijk om een perfecte classificatie te krijgen terwijl de snelheid hoog genoeg blijft om streaming niet te vertragen. Echter, voor het doel van het leesbaarder maken van tekst hoeft het niet perfect te zijn; eenvoudige heuristieken kunnen ons al ver genoeg brengen.
Toch zijn er zaken zoals sarcasme die interessant zouden zijn om via heuristieken aan te pakken, en gevallen van ontkenning met ingebedde clausules die mogelijk te parsen zijn. Daarnaast zouden voor streaming coding agents technische woorden waardevol kunnen zijn om aan het vocabulaire toe te voegen.
Code en demo beschikbaar op: github.com/RohanAdwankar/semfont