UNPKG

modern-text

Version:

Measure and render text in a way that describes the DOM.

324 lines (258 loc) 10.2 kB
<h1 align="center">modern-text</h1> <p align="center"> <a href="https://unpkg.com/modern-text"> <img src="https://img.shields.io/bundlephobia/minzip/modern-text" alt="Minzip"> </a> <a href="https://www.npmjs.com/package/modern-text"> <img src="https://img.shields.io/npm/v/modern-text.svg" alt="Version"> </a> <a href="https://www.npmjs.com/package/modern-text"> <img src="https://img.shields.io/npm/dm/modern-text" alt="Downloads"> </a> <a href="https://github.com/qq15725/modern-text/issues"> <img src="https://img.shields.io/github/issues/qq15725/modern-text" alt="Issues"> </a> <a href="https://github.com/qq15725/modern-text/blob/main/LICENSE"> <img src="https://img.shields.io/npm/l/modern-text.svg" alt="License"> </a> </p> `modern-text` measures and renders rich text on Canvas with a layout model that mirrors the browser's. It has no React/Vue dependency, ships ESM + CJS, and is **fully DOM-free** — the same measure → render pipeline runs in the browser, Node, SSR or Web Workers. ## Features - 📐 **DOM-accurate layout** — paragraphs, line wrapping, baselines, alignment. - 🧩 **Pure-JS, DOM-free layout** — the built-in `Measurer` computes layout from `modern-font` glyph metrics + kerning with no `document`, so it works in **Node / SSR / Workers**, is deterministic, and measures the exact font it renders. - ↔️ **Horizontal & vertical** writing modes (`horizontal-tb`, `vertical-rl`). - 🅰️ **Rich inline styling** — per-fragment font size/family/weight/style, color, letter-spacing, line-height, text-indent, text-align, vertical-align, text-decoration (underline / line-through / overline), text-transform, text-stroke, padding / margin. - 🎨 **Fills & strokes** — solid colors and linear gradients per fragment. - 🖍️ **Highlights** — draw an image/SVG behind selected fragments. - 🔵 **List markers** — `disc` / `none` / custom image bullets. - 🌑 **Effects** — stacked translate / skew / color layers (shadows, offsets). - 🌀 **Text deformation** — 34 opt-in presets (arch, bend, wave, trapezoid, ellipse, heart, …). - ✏️ **`<text-editor>` web component** — cursor, selection and keyboard editing. ## Install ```bash npm i modern-text modern-font ``` `modern-font` provides the font parsing/loading used for measuring and drawing glyphs. ## Quick start ```ts import { fonts } from 'modern-font' import { renderText } from 'modern-text' await fonts.loadFallbackFont('/fallback.woff') const view = document.createElement('canvas') document.body.append(view) renderText({ view, fonts, style: { width: 300, fontSize: 22, textDecoration: 'underline' }, content: [ { letterSpacing: 3, fragments: [ { content: 'He', color: 'red', fontSize: 12 }, { content: 'llo', color: 'black' }, ], }, { content: ', ', color: 'grey' }, { content: 'World!', color: 'black' }, ], }) ``` ## Layout backend `modern-text` measures with a single built-in **`Measurer`** — pure-JS and DOM-free — which resolves fonts from the `fonts` you pass or from `modern-font`'s global registry. It's the default; to plug in your own engine, pass any object implementing `TextMeasurer`: ```ts new Text({ fonts }) // uses the built-in Measurer (default) new Text({ measurer: myCustomMeasurer }) // any object implementing TextMeasurer ``` ### Node / SSR / Workers The built-in `Measurer` needs no `document`, so the whole measure → render pipeline runs outside the browser. Register fonts from a buffer with `modern-font`: ```ts import { readFileSync } from 'node:fs' import { Fonts, parseFont } from 'modern-font' import { Text } from 'modern-text' const buffer = readFileSync('./fonts/NotoSansSC.woff').buffer const font = parseFont(buffer) const sfnt = font.createSFNT() // .woff → SFNT const fonts = new Fonts() const entry = { src: '', familySet: new Set(['Noto']), buffer, getFont: () => font, getSFNT: () => sfnt } as any fonts.set('Noto', entry) fonts.setFallbackFont(entry) const text = new Text({ fonts, content: '你好世界', style: { fontFamily: 'Noto', fontSize: 32 } }) const result = text.measure() // → boxes for every paragraph / fragment / character ``` ## Content model Content is a hierarchy: **Text → Paragraph → Fragment → Character**. Each level inherits and merges style downward. `content` accepts several shapes that are normalized by [`modern-idoc`](https://github.com/qq15725/modern-idoc): ```ts // a plain string (single paragraph) content: 'Hello World' // an array of paragraphs, each a string or { content, ...paragraphStyle } content: [ { content: 'Title', fontSize: 40, textAlign: 'center' }, { content: 'Body text', color: '#333' }, ] // per-fragment styling inside a paragraph content: [ { textAlign: 'center', fragments: [ { content: 'red ', color: 'red' }, { content: 'bold', fontWeight: 'bold' }, ], }, ] ``` A newline (`\n`) splits into a new paragraph. ## Styling Style can be set at the text (root), paragraph, or fragment level. ```ts style: { // box width: 400, height: 200, padding: 16, // font fontSize: 24, fontFamily: 'Arial', fontWeight: 700, fontStyle: 'italic', // text color: '#222', lineHeight: 1.4, letterSpacing: 1, textIndent: 24, textAlign: 'center', // start | left | center | end | right verticalAlign: 'middle', // top | middle | bottom writingMode: 'vertical-rl', // horizontal-tb | vertical-rl textDecoration: 'underline', // underline | line-through | overline | none textTransform: 'uppercase', // uppercase | lowercase textStrokeWidth: 2, textStrokeColor: '#000', // outline stroke } ``` ### Gradient fills ```ts content: [{ fragments: [{ content: 'Gradient', fill: { linearGradient: { angle: 180, stops: [ { color: '#c7f1ff', offset: 0 }, { color: '#ffffff', offset: 1 }, ], }, }, }], }] ``` ### Highlights & list markers ```ts content: [ // image drawn behind the fragment { fragments: [{ content: 'highlighted', highlightImage: '/brush.svg' }] }, // list bullet { content: 'a bullet item', listStyleType: 'disc' }, { content: 'a custom bullet', listStyleImage: '/dot.svg' }, ] ``` ## Effects `effects` is an ordered stack of transform/color layers drawn behind the main glyphs — useful for shadows, 3D offsets and outlines. `translateX/Y` are fractions of the font size; `skewX/Y` are degrees. ```ts renderText({ view, fonts, content: 'Effect', style: { fontSize: 80, color: '#FEE90C' }, effects: [ { translateX: 0.05, translateY: 0.05, skewY: -5, color: '#000' }, // shadow { skewY: -5, color: '#FEE90C' }, // face ], }) ``` ## Text deformation Deformation presets are an opt-in subpath. Register them once, then set `deformation.type`: ```ts import { registerDeformations } from 'modern-text/deformations' import { renderText } from 'modern-text' registerDeformations() renderText({ view, fonts, content: 'Deformation', style: { fontSize: 64 }, deformation: { type: 'arch-curve', intensities: [50] }, }) ``` `intensities` is the strength of the effect, `0`–`100` per axis. Most presets read a single value (`intensities[0]`); a few are **two-axis** and also read `intensities[1]` — e.g. `skew` and `trapezoid` slant/taper independently on the x and y axes. A missing axis is treated as `0`. Deformation is **scale-invariant**: the geometry is derived from the text's own font size, so the same `intensities` produce the same shape at any `fontSize` — you don't pass a reference size. (The legacy `deformation.maxFontSize` field is no longer needed and is only used as a fallback when no glyphs are measurable.) <details> <summary>Available presets (34)</summary> `bend` · `bend-vertical` · `arch-curve` · `concave-curve` · `upper-arch-curve` · `lower-arch-curve` · `bulb-curve` · `skew` · `flag-curve` · `trapezoid` · `lower-trapezoid` · `top-trapezoid` · `horizontal-trapezoid` · `bevel` · `upper-roof` · `lower-roof` · `angled-projection` · `folded-corner` · `lateral-stretching` · `vertical-stretching` · `patchwork-by-word` · `step-by-word` · `arch2-by-word` · `wave-by-word` · `step-far-and-near-by-word` · `arch-far-and-near-by-word` · `horizontal-rotate-by-word` · `arbitrary-offset-rotate-by-word` · `horizontal-curved-rotate-by-word` · `ellipse-by-word` · `triangle-by-word` · `pentagon-by-word` · `rectangular-by-word` · `heart-by-word` Register your own with `defineDeformation(name, preset)`. </details> ## `Text` API For finer control, drive a `Text` instance directly: ```ts import { Text } from 'modern-text' const text = new Text({ fonts, content: 'Hello', style: { fontSize: 24 } }) text.on('update', () => text.render({ view })) // re-render on any change await text.load() // load async resources (fonts, plugin assets) text.update() // measure + commit + emit 'update' text.render({ view, pixelRatio: 2 }) text.boundingBox // overall box after measuring text.characters // flat list of measured Character (inlineBox / lineBox / path) text.dispose() // release the cached measurer / renderer ``` - `measure()` returns a non-destructive snapshot of all boxes. - `update()` measures and commits the result onto the instance. - `render({ view })` updates if needed, then draws. - Events: `update`, `measure`, `render`. ### One-shot helpers ```ts import { measureText, renderText } from 'modern-text' const result = measureText(options) // sync const result = await measureText(options, true) // load fonts first renderText({ view, ...options }) // sync await renderText({ view, ...options }, true) // load fonts first ``` ## `<text-editor>` web component ```ts import { TextEditor } from 'modern-text/web-components' TextEditor.register() ``` ```html <text-editor></text-editor> ``` ```ts const editor = document.querySelector('text-editor') editor.moveToDom(canvas) // overlay the editor on a rendered canvas editor.set(text) // bind a Text instance — provides cursor, selection, typing ``` ## License [MIT](./LICENSE)