Charming Pretext
Charming Pretext is a small layer on top of Pretext for text-based data visualization and generative art. It exposes an intuitive API for flowing text inside closed shapes described by an SVG d path. Pure-arithmetic measurement and line breaking keep Charming Pretext fast without DOM layout.

Demos
Live examples run at pretext.charmingjs.org. The source for those demos lives in the repo under demo/pretext.
Computing layout
Use cm.layoutTextInPath with a string and a closed path. It returns texts (fragments with positions and angles), lines (hachure segments, useful for debugging), and the font fields and path you passed in.
const layout = cm.layoutTextInPath({
text: "Hello, Charming Pretext! I love generative art!",
path: cm.pathCircle(160, 160, 150), // Builds a circle path string.
fontSize: 12,
fontFamily: "Inter",
});You then draw layout.texts with Canvas or SVG (see below).
Rendering with Canvas
Each item in layout.texts has text, x, y, and angle (in degrees). Apply transforms to the context before drawing each fragment.
renderPretextWithCanvas(Object.assign({}, layout, {width: 320, height: 320}));const context = cm.context2d({width: 400, height: 300});
context.fillStyle = "#222";
context.font = `${layout.fontSize}px ${layout.fontFamily}`;
context.textAlign = "center";
context.textBaseline = "middle";
for (const t of layout.texts) {
context.save();
context.translate(t.x, t.y);
context.rotate((t.angle * Math.PI) / 180);
context.fillText(t.text, 0, 0);
context.restore();
}Rendering with SVG
With cm.svg, bind texts to <text> nodes and use transform for position and rotation.
renderPretext(Object.assign({}, layout, {width: 320, height: 320}));const svg = cm.svg`<svg ${{
width: 320,
height: 320,
viewBox: "0 0 320 320",
}}>
<text ${{
data: layout.texts,
text_anchor: "middle",
dominant_baseline: "central",
font_size: `${layout.fontSize}px`,
font_family: layout.fontFamily,
transform: (d) => `translate(${d.x}, ${d.y}) rotate(${d.angle})`,
textContent: (d) => d.text,
}}/>
</svg>`;Setting line height
You can set line height by passing lineHeight into layoutTextInPath. The default is fontSize * 1.5.
renderPretext(
Object.assign(
{},
cm.layoutTextInPath({
text: "Hello, Charming Pretext! I love generative art!",
path: cm.pathCircle(160, 160, 150),
fontSize: 12,
fontFamily: "Inter",
lineHeight: 30,
}),
{width: 320, height: 320},
),
);const layout = cm.layoutTextInPath({
//...
lineHeight: 30,
});Rotating text
You can also change how the fill lines run by passing angle (degrees) into layoutTextInPath. That rotates the hachure direction and gives you more control over how the text follows the shape.
renderPretext(
Object.assign(
{},
cm.layoutTextInPath({
text: "Hello, Charming Pretext! I love generative art!",
path: cm.pathCircle(160, 160, 150),
fontSize: 12,
fontFamily: "Inter",
angle: 25,
}),
{width: 320, height: 320},
),
);const layout = cm.layoutTextInPath({
//...
angle: 25,
});Disabling repetition
By default, when the text runs out, the cursor resets and layout continues. Set repeat to false to stop instead of cycling—useful when you have enough text to fill the shape once.
renderPretext(
Object.assign(
{},
cm.layoutTextInPath({
text: "Hello, Charming Pretext! I love generative art!",
path: cm.pathCircle(160, 160, 150),
fontSize: 12,
fontFamily: "Inter",
repeat: false,
}),
{width: 320, height: 320},
),
);const layout = cm.layoutTextInPath({
//...
repeat: false,
});Preparing explicitly
By default, cm.layoutTextInPath prepares your string from the given font options and memoizes that work by text and font settings. As long as those stay the same, you can call it again with a new path (or other options) without remeasuring the string.
If you prefer to avoid that machinery—or you want to reuse one prepared value yourself—call cm.prepare explicitly and pass the result as prepared to cm.layoutTextInPath.
const prepared = cm.prepare(longText, {
fontSize: 14,
fontFamily: "Inter",
});
const layout = cm.layoutTextInPath({
text: longText,
prepared,
path: cm.pathCircle(200, 200, 150),
});How it works
First, the path is turned into polylines with points-on-path. Then hachure-fill generates parallel line segments inside the shape at lineHeight spacing, optionally rotated by angle. Finally, along each segment, Pretext’s layoutNextLine fills the available width with text from the prepared string. If you're interested in the implementation, please read the source code for more information. Suggestions and feedback are welcome!
cm.prepare(text, options)
Builds a Pretext prepared value with the specified options.
- fontSize — default
16. - fontFamily — default
"Inter". - fontStyle — default
"normal". - fontVariant — default
"normal". - fontWeight — default
"normal". - Any extra keys are forwarded to Pretext’s
prepareWithSegments.
The return value includes Pretext’s fields plus fontSize, fontFamily, fontStyle, fontVariant, and fontWeight for convenience.
const prepared = cm.prepare("Measure me", {
fontSize: 16,
fontFamily: "Inter",
});cm.layoutTextInPath(options)
Computes text positions with the specified options.
- text — source string (required).
- path — closed SVG path
d(required). - fontSize — default
16(same asprepare). - fontFamily — default
"Inter". - fontStyle — default
"normal". - fontVariant — default
"normal". - fontWeight — default
"normal". - prepared — optional Pretext prepared object from
prepare. - lineHeight — spacing between lines; default
fontSize * 1.5. - angle — rotation of lines in degrees; default
0. - repeat — whether to loop the text to fill the shape; default
true.
Returns an object with:
- texts — array of fragments.
- lines — array of segments
[[x1, y1], [x2, y2]]. - path — the input
dstring. - fontSize — effective font size used for the layout.
- fontFamily — effective font family.
- fontStyle — effective font style.
- fontVariant — effective font variant.
- fontWeight — effective font weight.
cm.clearPrepareCache()
Clears Charming’s memoized prepare cache and Pretext’s global clearCache(). Call it in long-running apps or tests if you need to free memory or reset measurement state.
cm.clearPrepareCache();function renderPretext({
texts,
lines,
width = 400,
height = 400,
fontSize = 16,
fontFamily = "Inter",
fontStyle = "normal",
fontVariant = "normal",
fontWeight = "normal",
path,
}) {
return cm.svg`<svg ${{
width,
height,
viewBox: `0 0 ${width} ${height}`,
overflow: "visible",
}}>
<path ${{
d: path,
fill: "none",
stroke: "black",
stroke_width: 1,
}}/>
<text ${{
data: texts,
text_anchor: "middle",
dominant_baseline: "central",
font_size: `${fontSize}px`,
font_family: fontFamily,
font_style: fontStyle,
font_variant: fontVariant,
font_weight: fontWeight,
transform: (text) => `translate(${text.x}, ${text.y}) rotate(${text.angle})`,
textContent: (text) => text.text,
}}/>
</svg>`;
}function renderPretextWithCanvas({
texts,
lines,
width = 400,
height = 400,
fontSize = 16,
fontFamily = "Inter",
fontStyle = "normal",
fontVariant = "normal",
fontWeight = "normal",
path,
}) {
const context = cm.context2d({width, height});
context.fillStyle = "#222";
context.font = `${fontSize}px ${fontFamily}`;
context.textAlign = "center";
context.textBaseline = "middle";
context.stroke(new Path2D(path));
for (const t of texts) {
context.save();
context.translate(t.x, t.y);
context.rotate((t.angle * Math.PI) / 180);
context.fillText(t.text, 0, 0);
context.restore();
}
return context.canvas;
}