Skip to content

FATCoNYC/composer

Repository files navigation

@fatconyc/composer

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.

Install

pnpm add @fatconyc/composer

Quick Start

import {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,
});

With Markdown

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

Multi-Column Layout

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

API

compose(options): JustifyResult

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
}

composeColumns(options): ColumnResult

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

renderToDOM(options) / renderColumnsToDOM(options)

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

Configuration

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

How Justification Works

Pipeline

Text/Markdown → Typographer's Quotes → Hyphenation → Line Breaking → Justification → Render
                                             │               │
                                     (styled runs     (styled runs
                                      preserved)       preserved)
  1. Markdown parsing (optional): Converts markdown to styled runs using micromark + mdast-util-from-markdown
  2. Typographer's quotes: Straight quotes, dashes, and ellipses are replaced with curly quotes, em/en dashes, and ellipsis characters
  3. 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.
  4. 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.
  5. Justification: Distributes slack across word spacing, letter spacing, and glyph scaling
  6. Column breaking (optional): Balanced or fill-first distribution across columns with orphan/widow constraints
  7. Rendering: CSS word-spacing for word gaps, letter-spacing for tracking, and transform: 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.

Justification Priority

When a line needs to be stretched or compressed, adjustments are applied in this order:

  1. Word spacing -- adjusted first (most natural, least visible)
  2. Letter spacing -- adjusted if word spacing hits its bounds
  3. Glyph scaling -- adjusted as a last resort within bounds
  4. Overflow -- any remaining slack goes back into word spacing

Optical Margin Alignment

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%): : ; ! ? ...

Line Breaking

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.

Column Breaking

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 specified columnHeight before overflowing to the next. Respects orphan/widow constraints.

Known Limitations

  • 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 calling compose().
  • 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.

Playground

An interactive playground is included for experimenting with all settings:

pnpm run playground

Then 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

Custom Rendering

compose() returns plain data, so you can build your own renderer for any framework or target (React, Vue, Canvas, SVG, etc.).

Line data

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
}

Applying spacing

The composer computes three spacing adjustments per line. Apply them in this order:

  1. Word spacing (wordGapPx): The exact gap between words. Use real space characters with CSS word-spacing set to wordGapPx - naturalSpaceWidth, or place words at explicit x-offsets (Canvas/SVG).
  2. Letter spacing (letterSpacingPx): Apply to every character on the line, including spaces.
  3. Glyph scaling (glyphScale): Horizontal scale applied to the entire line. In DOM, use transform: scaleX(). In Canvas, scale the context. Note: scaling affects word gaps and letter spacing too — wordGapPx is already corrected for this, so applying all three together produces the exact target line width.

Handling styled segments

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.

Grouping links and code spans

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 href into one anchor. Insert spaces between words inside the anchor so the underline renders continuously across word boundaries. Use word-spacing or equivalent to control the gap — don't use margins or padding, which break the underline.
  • Code: Group consecutive code segments 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++;
		}
	}
}

Incomplete lines

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.

Optical margins

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.

Copy/paste

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 copy event handler that reconstructs paragraph text from the line data, joining lines with spaces and paragraphs with newlines

Built On

About

A paragraph composer that is better than inDesign's with proper justification, optical margins/hanging punctuation, and editorial rags.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors