一种能读出你所写内容的字体
A font that reads what you wrote

原始链接: https://rohanadwankar.github.io/posts/semfont.html

**semfont** 是一个轻量级库,旨在通过根据内容的语义自动应用排版样式(包括颜色、字重、斜体和高亮),来提高文本的可读性。 该库使用四个核心指标来评估每个单词: * **效价(Valence):** 情感基调(积极或消极)。 * **显著性(Salience):** 文本中的重要性和频率。 * **惊奇度(Surprise):** 单词的意外程度或对比度。 * **确定性(Certainty):** 作者的自信程度。 semfont 使用两遍扫描算法,首先分析局部词汇模式,然后进行从句级分析,以处理复杂的语言细微差别,例如否定和情感解析(例如,识别出“修复”改善了“崩溃”)。 semfont 针对速度进行了优化,非常适合 AI 聊天界面等需要实时分析的流式应用。它提供灵活的集成方式,从简单的 React 组件到提供原始情感分数的框架无关引擎均可支持。用户还可以通过定义自己的词库来进一步定制该库。通过利用这些语义线索,semfont 能将密集的文本块转换为更易于浏览、更符合人类阅读习惯的信息。 **了解更多信息:** [github.com/RohanAdwankar/semfont](https://github.com/RohanAdwankar/semfont)

Rohan Adwankar 的项目“一种能读懂你所写内容的字体”(A font that reads what you wrote)在 Hacker News 上引发了热议。尽管标题如此,但用户们指出这并非字体,而是一个通过实时语义分析来高亮文本的 JavaScript 库。 该项目旨在通过强调关键词来提高高速文本流的可读性,类似于代码的语法高亮。虽然一些用户称赞该实验是对文本理解的一种创造性探索,并认为它可能对内部笔记或转录内容有用,但许多用户对用户体验提出了批评。批评者认为,高亮显示会产生视觉干扰,阻碍自然的阅读节奏,且无法顾及人类语言的细微差别——因为重点往往是主观的。 技术反馈褒贬不一,有人指出了浏览器兼容性问题,也有人认为标题具有误导性或“标题党”嫌疑。Adwankar 承认了这些反馈,澄清该工具使用的是轻量级启发式算法而非大语言模型,并表示对社区关于语义样式化的看法很感兴趣。总的来说,虽然这一概念被视为一个有趣的实验,但人们对其在主流阅读中的实用性持普遍怀疑态度。
相关文章

原文

semfont is a small library that sets typography automatically. As you can see below it automatically highlights, colors, bolds, and italicizes text which aims to make it easier to read.

How it works

Every word gets four scores. Each one starts as a dictionary lookup and is then adjusted by a couple of rules over the words around it.

Valence is how good or bad the word is, from -1 to 1. A negator up to three words back flips the sign and damps it, because not great is a mild complaint rather than the mirror image of praise. An intensifier up to two words back scales it instead.

let v = VALENCE[word] ?? 0;          // great -> 0.75
if (negatorWithin(3)) v = -v * 0.74; // not great -> -0.55
v *= gain;                           // really great -> 0.98

Salience is how much the word is worth looking at, 0 to 1. A frequency list gives each word a rarity, 0 for one of the hundred most common English words and 1 for one it has never seen. Rarity alone is not enough, so the score also rises with how often the word repeats in this particular text: an uncommon word you keep saying is what the text is about.

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);

// rarity('the') 0.00, rarity('kubelet') 0.93
// kubelet said once   -> 0.23
// kubelet said 3 times -> 0.51

Surprise is where the sentence turns, 0 to 1. Some words announce it on their own, like suddenly or ironically. Otherwise it comes from position: everything for six words after a contrast word gets it, decaying with distance, and so does any word much rarer than the rest of the passage.

let s = SURPRISE[word] ?? 0;         // suddenly -> 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

Certainty is how sure the writer sounds, -1 hedged to 1 asserted. Words like probably and definitely are in a table. But if you write The build probably failed, you are not unsure about the word probably, you are unsure about whether it failed. So the hedge keeps its own score and every other word in the sentence gets 55% of it, and the whole line leans a little instead of one word in the middle of it.

c = CERTAINTY[word] ?? sentenceCertainty * 0.55;

// 'The build probably failed.'
// probably -0.40, every other word -0.22

Then a theme maps each score to one typographic axis: valence to colour, salience to weight, surprise to a highlight, certainty to slant. Each axis has a threshold, so most words come out untouched.

Improving the algorithm

Those rules only look a few words either side, and that window has a blind spot. Write I would not go so far as to call the new editor great and great stays green, because the not that cancels it sits nine words back. Write We fixed the crash and you get one green word and one red one, because nothing connects fixed to the thing it fixed.

So a second pass now runs after the window rules and reads each clause as a whole. A negator reaches to the end of its clause and fades with distance, which turns great red. A verb like fixed, recovered or avoided marks whatever follows it as the thing that got better, which turns crash green. The same pass reads less broken and fewer complaints as improvements, too simple as a complaint, and a lone Great, in front of bad news as sarcasm.

Every change it makes is recorded on the word, so you can ask why a word came out the colour it did:

analyze('We fixed the crash.').tokens[6];
// { text: 'crash', valence: 0.44,
//   notes: ['resolved by "fixed"'] }

It costs about as much as the first pass and stays inside the budget, so there is no switch to flip. These are the ten sentences that led to it, including those two. Left is the first version, right is now.

before

now

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.

Using it

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>

Pick a theme, or only the channels you want:

<SemanticText text={incident} theme="monochrome" />
<SemanticText text={incident} channels={['valence']} />

Teach it your own vocabulary:

<SemanticText
  lexicon={{ valence: { flaky: -0.7, oncall: -0.4 }, salience: { rollback: 0.8 } }}
  text={incident}
/>

Now for the case I started this for. AI system stream in a large amount of text and its hard to read all of it so the intention of this is a library that can easily be tossed into most streaming components to make the text easier to read:

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>;
  });
}

Or skip React and take the scores. analyze is the engine alone, four numbers per word, no CSS, and these imports work with no React installed:

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%)' }

That last form is how this page works. There is no bundler here, so one import map tells the browser where semfont/analyze and semfont/theme live, pinned to a version on npm, and the demo box above is the same three lines as the React component: analyze, style, render.

<script type="importmap">
{ "imports": {
  "semfont/analyze": "https://cdn.jsdelivr.net/npm/[email protected]/src/analyze.js",
  "semfont/theme":   "https://cdn.jsdelivr.net/npm/[email protected]/src/theme.js"
} }
</script>
<script type="module">
  import { analyze } from 'semfont/analyze';
  import { styleFor, themes } from 'semfont/theme';
</script>

Next Steps

As you can probably guess based on the implementation it will be essentially impossible to get perfect classification while also being fast enough to not slow down the streaming. However for the purpose of making text easier to read it doesn't have to be perfect and some simple heuristics may end up taking us far enough away. That being said there are some case like sarcasm which would be interesting to try to tackle with heuristics and some cases like negation with embedded clauses which may be possible to parse out. Furthermore, for streaming coding agents there are technical words which could be worth including in the vocabulary.

Code and demo at github.com/RohanAdwankar/semfont.

联系我们 contact @ memedata.com