Skip to content

About

Paragraph optical density equalization — measure glyph area per line and equalize visual density

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Steady Gray

npm License: MIT part of liiift type-tools

Compositors call it colour — the aggregate grey of a text block. Steady Gray estimates each line’s raster density off-screen and applies bounded spacing changes toward a shared target. An aesthetic experiment in paragraph texture: the result depends on the text, font and settings, and reading benefit has not been evaluated.

The real steadyGray demo showing its bounded correction at default settings

steadygray.com · npm · GitHub

TypeScript · Canvas pixel sampling · React + Vanilla JS


Install

npm install @overpunch/steadygray

Usage

Next.js App Router: this library uses browser APIs. Add "use client" to any component file that imports from it.

React component

import { GrayValueText } from '@overpunch/steadygray'

<GrayValueText maxAdjustment={0.05} calibrationFactor={2} linePreservation="scale">
  Your paragraph text here...
</GrayValueText>

linePreservation="scale" scales corrected lines back to their original widths, changing glyph proportions as well as spacing. It is optional; the public default is 'none', which can overflow. Prefer a horizontally scrollable preview to clipping text; inspect the result at the intended reading size.

React hook

import { useGrayValue } from '@overpunch/steadygray'

// Inside a React component:
const ref = useGrayValue({ maxAdjustment: 0.05, calibrationFactor: 2 })
return <p ref={ref}>{children}</p>

The hook re-runs automatically on resize via ResizeObserver and after fonts load via document.fonts.ready.

Vanilla JS

import { applyGrayValue, removeGrayValue, getCleanHTML } from '@overpunch/steadygray/core'

const el = document.querySelector('p')!
const original = getCleanHTML(el)
const opts = { maxAdjustment: 0.05, calibrationFactor: 2 }

function run() {
  applyGrayValue(el, original, opts)
}

run()
document.fonts.ready.then(run)

const ro = new ResizeObserver(() => run())
ro.observe(el)

// Later — disconnect and restore original markup:
// ro.disconnect()
// removeGrayValue(el, original)

TypeScript

import type { GrayValueOptions } from '@overpunch/steadygray'

const opts: GrayValueOptions = { targetDensity: 0.35, maxAdjustment: 0.05, linePreservation: 'scale' }

Options

Option Default Description
active true Set false to skip all processing and restore the element to its original HTML
targetDensity 'auto' Target estimated density ratio (0–1). 'auto' averages all measured lines, including the final line
densityMode 'canvas' 'canvas' renders each line off-screen and counts ink pixels. 'glyph-path' uses opentype.js for an outline-area estimate from a supplied font file. It does not measure final screen pixels. Requires npm install opentype.js and a same-origin or CORS-enabled font URL via fontUrl; falls back to canvas while unavailable
fontUrl — URL of the font file for glyph-path measurement. Required when densityMode: 'glyph-path'. Must be same-origin or CORS-enabled
method 'letter-spacing' CSS property to adjust per line: 'letter-spacing', 'word-spacing', 'font-weight', or 'font-width'
maxAdjustment 0.05 Maximum correction magnitude. Units depend on method: em for letter/word-spacing, weight units for font-weight, wdth units for font-width. Defaults: 0.05 em for the spacing methods, 100 for font-weight, 30 for font-width. Weight and width methods apply a 1000× unit scale to the calibrated density difference
tolerance 0.01 Minimum calibrated adjustment to apply. At calibrationFactor: 2, tolerance: 0.01 skips density deviations below 0.005
calibrationFactor 2 Controller strength per density-unit difference (axis methods also apply a 1000× unit scale). Not physiological or display calibration
lineDetection 'bcr' 'bcr' reads actual browser layout — ground truth, works with any font and inline HTML. 'canvas' uses @chenglou/pretext for arithmetic line breaking with no forced reflow on resize (npm install @chenglou/pretext). Falls back to 'bcr' while pretext loads
linePreservation 'none' 'none' — line widths vary with the spacing correction. 'scale' — each corrected line gets a scaleX transform back to the width it had before the correction, so the column edge doesn't move (short lines are not stretched)
mode 'equalize' 'equalize' targets a shared estimated density. 'readability' is experimental: longer-word / higher-syllable-count lines receive lower targets and simpler lines higher ones, by up to ±10% of the average at strength: 1. Reading benefit has not been evaluated
complexity 'word-length' Heuristic for experimental readability mode: 'word-length' (no deps), 'syllable' (requires npm install syllable), or 'pos' (reserved; currently falls back to word length)
strength 0.5 How aggressively to weight complex lines in readability mode. Range 0–1
as 'p' HTML element to render. (React component only)

API reference

Export Description
applyGrayValue(el, originalHTML, options?) Estimate raster density per line and apply one bounded correction pass.
removeGrayValue(el, originalHTML) Restore the element to its original markup.
getCleanHTML(el) Return the element's inner HTML with all injected spans removed.
measureLineDensity(text, fontStyle, lineHeight, canvas, darkMode?) Low-level canvas primitive: render one line of text into canvas and return its ink pixel ratio (0–1). fontStyle is a canvas font string (e.g. "400 18px Georgia"); lineHeight is the canvas height in CSS px; pass darkMode: true to count light-on-dark pixels as ink.
useGrayValue React hook: (options: GrayValueOptions) => ref. Re-runs on resize and after fonts load.
GrayValueText React component. Accepts all GrayValueOptions plus as prop.
GrayValueOptions TypeScript interface for all options.
GRAY_VALUE_CLASSES CSS class names injected by the algorithm (gv-word, gv-line, gv-probe).

How it works

Real browser captures of Before and After at identical width and default letter-spacing settings

Two actual demo captures: Before restores the original layout; After runs the tool at maxAdjustment: 0.05, calibrationFactor: 2 and the other public defaults. No density meters or convergence illustration are fabricated. Use the live Before / After buttons at the same width, then Reset defaults to return to the starting settings. See asset provenance.

Each detected line is redrawn to an off-screen Canvas using computed font style, size, weight and family. The Canvas is sized to the text’s own measured width and line height, not the full column. Pixels below a fixed RGB threshold are counted as ink in dark-on-light mode; the polarity is reversed for light-on-dark contexts. This is a synthetic raster estimate, not a screenshot of the DOM: inline styling, variable-font axes and author spacing can differ from the redraw.

The average estimate across all lines becomes the target (or set targetDensity manually). Each line receives a correction proportional to its deviation, clamped to maxAdjustment and suppressed below the calibrated tolerance. Each run makes one correction pass, without remeasuring the corrected output to establish convergence. The React wrapper re-runs on resize and after fonts finish loading.

Measured limits: a 2026-11-06 audit of published 1.3.1’s previous demo paragraph found lower screenshot-density variation at a 390px viewport, while 768px and 1280px samples showed no change among non-final lines at defaults. Those results do not predict this revised paragraph, every font or another width. A raster change does not establish visibility, preference, comfort, comprehension or reading speed. No perception study or display-specific benefit is claimed.

Start with native layout: for rag, short last lines and hyphenation, try text-wrap: pretty and hyphens: auto with the correct lang. Browser behavior varies. Review font, measure, size and leading first; use per-line correction when you want that specific aesthetic intervention and accept its tradeoffs.

Line break safety: each run starts from the original content, wraps every word in a plain inline span (spaces between words stay in the text flow, so the layout is the browser's own), groups the words into lines, then locks each line with white-space: nowrap. Lines keep exactly the words the browser put on them, including a word the browser itself breaks at a hyphen or with overflow-wrap; CJK and Thai break between characters as usual. Automatic hyphenation (hyphens: auto, &shy;) can't happen inside a locked line, so such a word moves whole to the next line. Justified text stays justified and text-indent applies to the first line only.

Markup and accessibility: inline elements (<em>, <a>, <strong>…), your own <br> and images, and the spaces between elements are kept, and the original elements are reused, so event listeners on them (React's included) keep working. An element that runs across a line break is split into one copy per line (a link over two lines becomes two links to the same place; only the first keeps its id). getCleanHTML() returns the original markup. Injected line breaks are aria-hidden; copied text includes a line break at each line end.

Width overflow: The spacing correction intentionally changes each line's visual width. The maximum change is maxAdjustment × characterCount. At the default maxAdjustment: 0.05em and 60 characters per line at 16px, peak overflow is approximately 48px. Use linePreservation: 'scale' to return each line to its original width (at the cost of changing glyph proportions), or put the paragraph in a horizontally scrollable wrapper. Do not clip reading content to hide overflow.

Even colour vs. even rhythm: Per-line corrections can make tracking uneven. Keep changes small, compare at the intended reading size, and use the original layout if the tradeoff is undesirable. A small bound does not establish that the change is invisible. Lines whose calibrated adjustment is below tolerance are left untouched.


Performance & compatibility

  • Cost: Each run measures every detected line by rendering it to a single reused off-screen Canvas and reading it back with getImageData. The measurement context is created with willReadFrequently: true, which requests a context optimized for frequent pixel readback; the browser chooses the backing strategy. The pass re-runs on ResizeObserver (the React hook) and once after document.fonts.ready. For content-heavy pages with many independently equalized paragraphs, debounce your own resize handling and prefer lineDetection: 'canvas' (@chenglou/pretext), which breaks lines arithmetically without forcing a layout reflow on resize.
  • Graceful no-op: Lines below the calibrated adjustment tolerance are skipped, and applyGrayValue returns immediately when window is undefined (SSR-safe). Setting active: false restores the element to its original HTML.
  • Slow-update guard: If matchMedia('(update: slow)') matches, the core restores original markup and skips measurement and correction. This is not device identification; no e-ink, smart-glasses or automotive HUD benefit has been validated.
  • Browser requirements: Canvas 2D + getImageData, ResizeObserver, and document.fonts.ready — all available in evergreen browsers. The font-weight and font-width methods additionally require a variable font with the corresponding wght / wdth axis. There are no IE or legacy fallbacks.
  • React is optional. The main entry also exports the React hook and component, so it imports react; without React installed, import the vanilla API from @overpunch/steadygray/core.
  • Dependencies: No required runtime dependencies for the default core path. Optional features use the optional packages noted in the Options table (opentype.js for glyph-path density, @chenglou/pretext for canvas line detection, syllable for syllable-based readability complexity).

Dev notes

next in root devDependencies

package.json at the repo root lists next as a devDependency. This is a Vercel detection workaround — not a real dependency of the npm package. Vercel's build system inspects the root package.json to detect the framework; without next present it falls back to a static build and skips the Next.js pipeline, breaking the /site subdirectory deploy.

The default core path needs no runtime dependency. Optional packages support the opt-in modes. Do not remove this entry.


Future improvements

  • Variable axis measurement parity — weight and width correction already exist; improve how the off-screen estimator follows the actual variable-font instance and shaping
  • Configurable canvas DPR — allow overriding the device pixel ratio used for the measurement canvas, to trade accuracy for performance on high-density displays
  • Iterative convergence — apply corrections in multiple passes until all lines converge within tolerance, rather than a single-pass linear estimate
  • Per-paragraph target — measureLineDensity is exported as a low-level canvas primitive; a high-level measureDensity(el) wrapper that takes just an element would allow cross-paragraph normalization without manually building font strings and canvas contexts

About

Paragraph optical density equalization — measure glyph area per line and equalize visual density

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages