Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion demo/pretext/us-map-paper/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ function main() {
fontStyle: "normal",
fontVariant: "normal",
fontWeight: "normal",
fontSize: 18,
fontSize: 16,
};

const layer = document.querySelector("#stage .stage__inner");
Expand Down
13 changes: 13 additions & 0 deletions docs/.vitepress/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ export default defineConfig({
text: "Examples",
link: "https://observablehq.com/d/18b3d6f3affff5bb",
},
{
text: "Demos",
items: [
{
text: "Pretext",
link: "https://pretext.charmingjs.org/",
},
],
},
],
sidebar: [
{
Expand Down Expand Up @@ -59,6 +68,10 @@ export default defineConfig({
text: "Charming Path",
link: "/path",
},
{
text: "Charming Pretext",
link: "/charming-pretext",
},
],
},
],
Expand Down
8 changes: 8 additions & 0 deletions docs/api-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,11 @@ Build SVG path strings for common shapes.
- [_cm_.**pathRect**](/path#cm-path-rect) — closed rectangle.
- [_cm_.**pathEllipse**](/path#cm-path-ellipse) — closed axis-aligned ellipse.
- [_cm_.**pathPolygon**](/path#cm-path-polygon) — closed path through `[x, y]` points.

## [Charming Pretext](/charming-pretext)

Text measurement and path-bound layout on top of [Pretext](https://github.com/chenglou/pretext).

- [_cm_.**prepare**](/charming-pretext#cm-prepare) — prepare a string with font options for Pretext.
- [_cm_.**layoutTextInPath**](/charming-pretext#cm-layout-text-in-path) — flow prepared text through a closed SVG path.
- [_cm_.**clearPrepareCache**](/charming-pretext#cm-clear-prepare-cache) — clear memoized prepare and Pretext caches.
307 changes: 307 additions & 0 deletions docs/charming-pretext.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
# Charming Pretext

**Charming Pretext** is a small layer on top of [Pretext](https://github.com/chenglou/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`](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d) path. Pure-arithmetic measurement and line breaking keep Charming Pretext fast without DOM layout.

![pretext-map](/pretext/map.png)

## Demos

Live examples run at [pretext.charmingjs.org](https://pretext.charmingjs.org/). The source for those demos lives in the repo under [`demo/pretext`](https://github.com/charming-art/api/tree/main/demo/pretext).

## Computing layout

Use [_cm_.**layoutTextInPath**](#cm-layout-text-in-path) 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.

```js eval
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.

```js eval code=false
renderPretextWithCanvas(Object.assign({}, layout, {width: 320, height: 320}));
```
Comment thread
pearmini marked this conversation as resolved.

```js
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();
}
Comment thread
pearmini marked this conversation as resolved.
```

## Rendering with SVG

With [_cm_.**svg**](/dom#cm-svg), bind `texts` to `<text>` nodes and use `transform` for position and rotation.

```js eval code=false
renderPretext(Object.assign({}, layout, {width: 320, height: 320}));
```

```js
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`.

```js eval code=false
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},
),
);
```

```js
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.

```js eval code=false
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},
),
);
```

```js
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.

```js eval code=false
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},
),
);
```

```js
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`.

```js
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`](https://github.com/subairui/points-on-path). Then [`hachure-fill`](https://github.com/rough-stuff/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](https://github.com/charming-art/api/blob/main/src/pretext/index.js) for more information. Suggestions and feedback are welcome!
Comment thread
pearmini marked this conversation as resolved.

## _cm_.prepare(_text_, _options_) {#cm-prepare}

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`](https://github.com/chenglou/pretext).

The return value includes Pretext’s fields plus `fontSize`, `fontFamily`, `fontStyle`, `fontVariant`, and `fontWeight` for convenience.

```js
const prepared = cm.prepare("Measure me", {
fontSize: 16,
fontFamily: "Inter",
});
```

## _cm_.layoutTextInPath(_options_) {#cm-layout-text-in-path}

Computes text positions with the specified options.

- **text** — source string (required).
- **path** — closed SVG path `d` (required).
- **fontSize** — default `16` (same as [`prepare`](#cm-prepare)).
- **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 `d` string.
- **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() {#cm-clear-prepare-cache}

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.

```js
cm.clearPrepareCache();
```

```js eval code=false inspector=false
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>`;
}
```

```js eval code=false inspector=false
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;
}
```
Binary file added docs/public/pretext/map.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading