A paragraph composer that is better than inDesign's with proper justification, optical margins/hanging punctuation, editorial rags, multi-column layout, and inline markdown styling. Built on @chenglou/pretext.
pnpm add @fatconyc/composerimport {compose, renderToDOM} from '@fatconyc/composer';
const result = compose({
text: 'Your paragraph text here...',
font: '16px Georgia',
containerWidth: 480,
});
renderToDOM({
container: document.getElementById('output'),
result,
font: '16px Georgia',
containerWidth: 480,
});const result = compose({
text: 'Typography is the **art and technique** of arranging type.',
font: '16px Georgia',
containerWidth: 480,
markdown: true,
});Bold, italic, inline code, and links are parsed and rendered with per-run font resolution. Custom fonts can be provided:
const result = compose({
text: 'Hello **world** and `code`',
font: '16px Georgia',
containerWidth: 480,
markdown: true,
fonts: {
bold: 'bold 16px Georgia',
italic: 'italic 16px Georgia',
code: '14px "Fira Code", monospace',
},
});Or derive fonts from your page's CSS automatically:
import {resolveFontsFromCSS} from '@fatconyc/composer';
const fonts = resolveFontsFromCSS(document.body, '16px Georgia');import {composeColumns, renderColumnsToDOM} from '@fatconyc/composer';
// Pure computation — pass column widths directly, no DOM needed
const result = composeColumns({
text: 'Long text...',
font: '16px Georgia',
columns: [300, 300], // array of column widths in px
markdown: true,
config: {
columnGap: 24,
columnBalance: 'balanced', // or 'fill-first'
columnOrphans: 2,
columnWidows: 2,
},
});
// Or read column geometry from a CSS grid element
const result = composeColumns({
text: 'Long text...',
font: '16px Georgia',
columns: document.querySelector('.grid-container'),
});
// Render into a grid container
renderColumnsToDOM({
container: document.querySelector('.grid-container'),
result,
font: '16px Georgia',
});Runs the composition engine on a block of text. Respects paragraph breaks (\n).
interface ComposeOptions {
text: string; // The text to compose
font: string; // CSS font shorthand (e.g., "16px Georgia")
containerWidth: number; // Container width in pixels
config?: Partial<JustifyConfig>;
markdown?: boolean; // Parse text as markdown with inline styling
fonts?: FontMap; // Custom fonts for bold/italic/code
}Returns a JustifyResult with per-line data:
interface JustifyResult {
lines: JustifiedLine[]; // Per-line adjustment data
totalHeight: number; // Total height of the composed text
lineHeight: number; // Computed line height in pixels
gridIncrement: number; // Active baseline grid increment
}
interface JustifiedLine {
segments: string[]; // Words on this line
styledSegments?: StyledSegment[]; // Styled runs per word (when markdown is used)
isLastLine: boolean; // Last line of a paragraph
wordGapPx: number; // Exact pixel gap between words
letterSpacingPx: number; // Letter spacing adjustment in px
glyphScale: number; // Horizontal glyph scale (1 = normal)
y: number; // Y position
hangLeft: number; // Left hanging punctuation offset in px
hangRight: number; // Right hanging punctuation offset in px
}Composes text across multiple columns with optimal column breaking.
interface ColumnComposeOptions {
text: string;
font: string;
columns: number[] | HTMLElement; // Column widths array or CSS grid element
config?: Partial<ColumnConfig>;
markdown?: boolean;
fonts?: FontMap;
columnHeight?: number; // Max column height for fill-first mode
}
interface ColumnConfig extends JustifyConfig {
columnBreakPenalty: number; // Cost of mid-paragraph breaks (default: 100)
columnBalance: 'balanced' | 'fill-first';
columnOrphans: number; // Min lines at column top (default: 2)
columnWidows: number; // Min lines at column bottom (default: 2)
columnGap: number | 'auto'; // Gap in px, or 'auto' to read from CSS grid
maxColumns: number; // Cap column count (default: Infinity)
}Renders justified text into a DOM container.
interface RenderOptions {
container: HTMLElement;
result: JustifyResult;
font: string;
containerWidth: number;
lastLineAlignment?: 'left' | 'right' | 'center' | 'full';
singleWordJustification?: 'left' | 'full' | 'right' | 'center';
textMode?: 'justify' | 'rag';
showGuides?: boolean;
onTextChange?: (newText: string) => void;
}All settings mirror InDesign's Justification panel. Each spacing axis has min, desired, and max values.
import {compose, DEFAULT_CONFIG} from '@fatconyc/composer';
const result = compose({
text: '...',
font: '16px Georgia',
containerWidth: 480,
config: {
// Word spacing (100% = normal space width)
wordSpacing: {min: 75, desired: 85, max: 110},
// Letter spacing (0% = normal)
letterSpacing: {min: -2, desired: 0, max: 4},
// Glyph scaling (100% = no scaling)
glyphScaling: {min: 98, desired: 100, max: 102},
// Auto leading as % of font size
autoLeading: 120,
// Line breaking algorithm
composer: 'paragraph', // 'paragraph' (Knuth-Plass) | 'greedy'
// Text mode
textMode: 'justify', // 'justify' | 'rag'
// How to align the last line of a paragraph
lastLineAlignment: 'left', // 'left' | 'right' | 'center' | 'full'
// How to handle lines with a single word
singleWordJustification: 'left', // 'left' | 'right' | 'center' | 'full'
// Hanging punctuation (Optical Margin Alignment)
opticalAlignment: false,
// Prevent single-word last lines
avoidWidows: true,
// Baseline grid snap (0 = disabled)
baselineGrid: 0,
// Replace straight quotes/dashes/ellipses with typographic equivalents
typographersQuotes: true,
// Hyphenation (false to disable)
hyphenation: {
minWordLength: 5,
afterFirst: 4,
beforeLast: 3,
maxConsecutive: 2,
},
},
});Text/Markdown → Typographer's Quotes → Hyphenation → Line Breaking → Justification → Render
│ │
(styled runs (styled runs
preserved) preserved)
- Markdown parsing (optional): Converts markdown to styled runs using micromark + mdast-util-from-markdown
- Typographer's quotes: Straight quotes, dashes, and ellipses are replaced with curly quotes, em/en dashes, and ellipsis characters
- Hyphenation: Soft hyphens are inserted at valid break points using language-aware rules. Punctuation is stripped before dictionary lookup and reattached to syllables. Hard hyphens in compound words (e.g., "line-spacing") are treated as free (zero-cost) break points, always preferred over soft hyphens.
- Line breaking: Knuth-Plass evaluates all possible break points across the paragraph to minimize overall "badness," or greedy breaks line-by-line. A two-tier adjustment ratio penalizes glyph compression more steeply than word spacing changes, preferring hyphenation over squishing.
- Justification: Distributes slack across word spacing, letter spacing, and glyph scaling
- Column breaking (optional): Balanced or fill-first distribution across columns with orphan/widow constraints
- Rendering: CSS
word-spacingfor word gaps,letter-spacingfor tracking, andtransform: scaleX()for glyph scaling. Real space characters between words enable correct copy/paste. Styled text renders nested spans with per-run fonts; links use continuous underlines across word boundaries.
When a line needs to be stretched or compressed, adjustments are applied in this order:
- Word spacing -- adjusted first (most natural, least visible)
- Letter spacing -- adjusted if word spacing hits its bounds
- Glyph scaling -- adjusted as a last resort within bounds
- Overflow -- any remaining slack goes back into word spacing
When opticalAlignment is enabled, punctuation at line edges hangs outside the text block so letter edges create a cleaner visual alignment. This is what InDesign calls "Optical Margin Alignment."
Characters that hang fully (100% of width): " " ' ' " ' - -- --- . ,
Characters that hang partially (50%): : ; ! ? ...
Two composers are available:
'paragraph'(default) -- Knuth-Plass optimal line breaking. Considers all possible break points across the entire paragraph to minimize overall "badness." Produces the best results. Required for rag mode and markdown styling.'greedy'-- Single-line-at-a-time breaking via pretext. Faster, matches browser behavior. Does not support rag tuning or styled text.
Two modes are available:
'balanced'(default) -- Binary searches for the minimum column height where all text fits, breaking mid-paragraph as needed. Distance from ideal fill dominates the cost model, with paragraph boundaries as tiebreakers.'fill-first'-- Fills each column to the specifiedcolumnHeightbefore overflowing to the next. Respects orphan/widow constraints.
- Canvas vs DOM measurement: Text width is measured via canvas
measureText(), but rendered in DOM spans. Sub-pixel differences between the two can cause lines to be slightly under- or over-filled (typically < 1px). - Greedy composer + markdown/rag: The greedy composer does not support styled text or rag tuning.
- Hyphenation language: Currently hardcoded to English (
en-us). Other languages are not yet supported. - Font loading:
compose()measures text immediately. If the font hasn't loaded yet, measurements will use a fallback font. Ensure fonts are loaded before callingcompose(). - Markdown scope: Inline styles are supported (
**bold**,*italic*,`code`,[links](url)). Block-level elements not yet supported: lists (ordered/unordered), blockquotes, tables, images, horizontal rules, and code blocks. Headings are treated as bold paragraphs with scaled font size.
An interactive playground is included for experimenting with all settings:
pnpm run playgroundThen open http://localhost:3000. Features:
- Markdown text editor with live preview
- Random sample texts from Project Gutenberg classics
- Multi-column layout with balanced/fill-first controls
- Live sliders for all justification parameters
- Alignment toolbar (Left / Center / Right / Full)
- Composer toggle (Knuth-Plass / Greedy)
- Rag mode with balance controls
- Browser comparison mode
- Visual guides: margin lines and baseline grid
- Optical Margin Alignment toggle
- Typographer's quotes toggle
- Hyphenation controls
- Copy/paste preserves paragraph structure
compose() returns plain data, so you can build your own renderer for any framework or target (React, Vue, Canvas, SVG, etc.).
Each line in result.lines provides everything needed to render:
const result = compose({text, font, containerWidth, markdown: true});
for (const line of result.lines) {
line.segments // string[] — words on this line (plain text)
line.styledSegments // StyledSegment[] — styled runs per word (when markdown is used)
line.wordGapPx // number — exact pixel gap between words
line.letterSpacingPx // number — letter spacing adjustment in px
line.glyphScale // number — horizontal scale factor (1 = normal)
line.hangLeft // number — left optical margin offset in px
line.hangRight // number — right optical margin offset in px
line.y // number — vertical position in px
line.isLastLine // boolean — last line of a paragraph
}The composer computes three spacing adjustments per line. Apply them in this order:
- Word spacing (
wordGapPx): The exact gap between words. Use real space characters with CSSword-spacingset towordGapPx - naturalSpaceWidth, or place words at explicit x-offsets (Canvas/SVG). - Letter spacing (
letterSpacingPx): Apply to every character on the line, including spaces. - Glyph scaling (
glyphScale): Horizontal scale applied to the entire line. In DOM, usetransform: scaleX(). In Canvas, scale the context. Note: scaling affects word gaps and letter spacing too —wordGapPxis already corrected for this, so applying all three together produces the exact target line width.
When markdown: true, each line has styledSegments — an array of word-level segments, each containing one or more styled runs:
interface StyledSegment {
runs: ResolvedRun[] // one or more runs that make up this word
}
interface ResolvedRun {
text: string // text content
font: string // resolved CSS font string (e.g., "bold 16px Georgia")
style: {
bold?: boolean
italic?: boolean
code?: boolean
href?: string // link URL
fontSize?: number
}
}A single word like "HelloWorld" becomes one segment with two runs (bold "Hello", normal "World"). Spaces only appear between segments, never inside them.
Consecutive segments sharing the same href or code style should be grouped under a single wrapper to produce correct visual output:
- Links: Group consecutive segments with the same
hrefinto one anchor. Insert spaces between words inside the anchor so the underline renders continuously across word boundaries. Useword-spacingor equivalent to control the gap — don't use margins or padding, which break the underline. - Code: Group consecutive
codesegments under one wrapper for a continuous background.
Example grouping logic:
for (const line of result.lines) {
let i = 0;
while (i < line.styledSegments.length) {
const seg = line.styledSegments[i];
const href = seg.runs[0]?.style.href;
if (href && seg.runs.every(r => r.style.href === href)) {
// Collect consecutive segments with the same href
const group = [];
while (i < line.styledSegments.length &&
line.styledSegments[i].runs.every(r => r.style.href === href)) {
group.push(line.styledSegments[i]);
i++;
}
renderLink(href, group); // render as one <a> / annotation
} else if (seg.runs.every(r => r.style.code)) {
// Collect consecutive code segments
const group = [];
while (i < line.styledSegments.length &&
line.styledSegments[i].runs.every(r => r.style.code)) {
group.push(line.styledSegments[i]);
i++;
}
renderCode(group); // render as one <code> / styled block
} else {
renderSegment(seg);
i++;
}
}
}The last line of a paragraph (isLastLine) and single-word lines typically should not be justified. Instead, use lastLineAlignment to align them (left, right, center, or full). When not fully justified, ignore wordGapPx and use natural word spacing.
When opticalAlignment is enabled, hangLeft and hangRight indicate how far punctuation extends beyond the text block edges. Only hangLeft requires explicit handling — offset the line start by -hangLeft. hangRight is already baked into wordGapPx by the composer (the extra width from both hangs is distributed into the word gap calculation), so the last character naturally extends past the right margin without any additional offset.
If your renderer uses absolute positioning or non-flow layout, copied text may lose spaces or line breaks. Strategies to fix this:
- Insert real space characters between words (not just visual gaps)
- Add a
copyevent handler that reconstructs paragraph text from the line data, joining lines with spaces and paragraphs with newlines
- @chenglou/pretext -- Fast, reflow-free text measurement and line breaking
- hyphen -- Language-aware automatic hyphenation
- micromark + mdast-util-from-markdown -- Markdown parsing