@fatconyc/composer
v0.3.2
Published
A paragraph composer that is better than inDesign's with proper justification, optical margins/hanging punctuation, and editorial rags.
Maintainers
Readme
@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/composerQuick 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)- 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.
Justification Priority
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
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 specifiedcolumnHeightbefore 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 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.
Playground
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
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:
- 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.
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
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++;
}
}
}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
copyevent handler that reconstructs paragraph text from the line data, joining lines with spaces and paragraphs with newlines
Built On
- @chenglou/pretext -- Fast, reflow-free text measurement and line breaking
- hyphen -- Language-aware automatic hyphenation
- micromark + mdast-util-from-markdown -- Markdown parsing
