|
| 1 | +# Charming Pretext |
| 2 | + |
| 3 | +**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. |
| 4 | + |
| 5 | + |
| 6 | + |
| 7 | +## Demos |
| 8 | + |
| 9 | +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). |
| 10 | + |
| 11 | +## Computing layout |
| 12 | + |
| 13 | +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. |
| 14 | + |
| 15 | +```js eval |
| 16 | +const layout = cm.layoutTextInPath({ |
| 17 | + text: "Hello, Charming Pretext! I love generative art!", |
| 18 | + path: cm.pathCircle(160, 160, 150), // Builds a circle path string. |
| 19 | + fontSize: 12, |
| 20 | + fontFamily: "Inter", |
| 21 | +}); |
| 22 | +``` |
| 23 | + |
| 24 | +You then draw `layout.texts` with Canvas or SVG (see below). |
| 25 | + |
| 26 | +## Rendering with Canvas |
| 27 | + |
| 28 | +Each item in `layout.texts` has `text`, `x`, `y`, and `angle` (in degrees). Apply transforms to the context before drawing each fragment. |
| 29 | + |
| 30 | +```js eval code=false |
| 31 | +renderPretextWithCanvas(Object.assign({}, layout, {width: 320, height: 320})); |
| 32 | +``` |
| 33 | + |
| 34 | +```js |
| 35 | +const context = cm.context2d({width: 400, height: 300}); |
| 36 | +context.fillStyle = "#222"; |
| 37 | +context.font = `${layout.fontSize}px ${layout.fontFamily}`; |
| 38 | +context.textAlign = "center"; |
| 39 | +context.textBaseline = "middle"; |
| 40 | + |
| 41 | +for (const t of layout.texts) { |
| 42 | + context.save(); |
| 43 | + context.translate(t.x, t.y); |
| 44 | + context.rotate((t.angle * Math.PI) / 180); |
| 45 | + context.fillText(t.text, 0, 0); |
| 46 | + context.restore(); |
| 47 | +} |
| 48 | +``` |
| 49 | + |
| 50 | +## Rendering with SVG |
| 51 | + |
| 52 | +With [_cm_.**svg**](/dom#cm-svg), bind `texts` to `<text>` nodes and use `transform` for position and rotation. |
| 53 | + |
| 54 | +```js eval code=false |
| 55 | +renderPretext(Object.assign({}, layout, {width: 320, height: 320})); |
| 56 | +``` |
| 57 | + |
| 58 | +```js |
| 59 | +const svg = cm.svg`<svg ${{ |
| 60 | + width: 320, |
| 61 | + height: 320, |
| 62 | + viewBox: "0 0 320 320", |
| 63 | +}}> |
| 64 | + <text ${{ |
| 65 | + data: layout.texts, |
| 66 | + text_anchor: "middle", |
| 67 | + dominant_baseline: "central", |
| 68 | + font_size: `${layout.fontSize}px`, |
| 69 | + font_family: layout.fontFamily, |
| 70 | + transform: (d) => `translate(${d.x}, ${d.y}) rotate(${d.angle})`, |
| 71 | + textContent: (d) => d.text, |
| 72 | + }}/> |
| 73 | +</svg>`; |
| 74 | +``` |
| 75 | + |
| 76 | +## Setting line height |
| 77 | + |
| 78 | +You can set line height by passing **`lineHeight`** into `layoutTextInPath`. The default is `fontSize * 1.5`. |
| 79 | + |
| 80 | +```js eval code=false |
| 81 | +renderPretext( |
| 82 | + Object.assign( |
| 83 | + {}, |
| 84 | + cm.layoutTextInPath({ |
| 85 | + text: "Hello, Charming Pretext! I love generative art!", |
| 86 | + path: cm.pathCircle(160, 160, 150), |
| 87 | + fontSize: 12, |
| 88 | + fontFamily: "Inter", |
| 89 | + lineHeight: 30, |
| 90 | + }), |
| 91 | + {width: 320, height: 320}, |
| 92 | + ), |
| 93 | +); |
| 94 | +``` |
| 95 | + |
| 96 | +```js |
| 97 | +const layout = cm.layoutTextInPath({ |
| 98 | + //... |
| 99 | + lineHeight: 30, |
| 100 | +}); |
| 101 | +``` |
| 102 | + |
| 103 | +## Rotating text |
| 104 | + |
| 105 | +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. |
| 106 | + |
| 107 | +```js eval code=false |
| 108 | +renderPretext( |
| 109 | + Object.assign( |
| 110 | + {}, |
| 111 | + cm.layoutTextInPath({ |
| 112 | + text: "Hello, Charming Pretext! I love generative art!", |
| 113 | + path: cm.pathCircle(160, 160, 150), |
| 114 | + fontSize: 12, |
| 115 | + fontFamily: "Inter", |
| 116 | + angle: 25, |
| 117 | + }), |
| 118 | + {width: 320, height: 320}, |
| 119 | + ), |
| 120 | +); |
| 121 | +``` |
| 122 | + |
| 123 | +```js |
| 124 | +const layout = cm.layoutTextInPath({ |
| 125 | + //... |
| 126 | + angle: 25, |
| 127 | +}); |
| 128 | +``` |
| 129 | + |
| 130 | +## Disabling repetition |
| 131 | + |
| 132 | +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. |
| 133 | + |
| 134 | +```js eval code=false |
| 135 | +renderPretext( |
| 136 | + Object.assign( |
| 137 | + {}, |
| 138 | + cm.layoutTextInPath({ |
| 139 | + text: "Hello, Charming Pretext! I love generative art!", |
| 140 | + path: cm.pathCircle(160, 160, 150), |
| 141 | + fontSize: 12, |
| 142 | + fontFamily: "Inter", |
| 143 | + repeat: false, |
| 144 | + }), |
| 145 | + {width: 320, height: 320}, |
| 146 | + ), |
| 147 | +); |
| 148 | +``` |
| 149 | + |
| 150 | +```js |
| 151 | +const layout = cm.layoutTextInPath({ |
| 152 | + //... |
| 153 | + repeat: false, |
| 154 | +}); |
| 155 | +``` |
| 156 | + |
| 157 | +## Preparing explicitly |
| 158 | + |
| 159 | +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. |
| 160 | + |
| 161 | +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`. |
| 162 | + |
| 163 | +```js |
| 164 | +const prepared = cm.prepare(longText, { |
| 165 | + fontSize: 14, |
| 166 | + fontFamily: "Inter", |
| 167 | +}); |
| 168 | + |
| 169 | +const layout = cm.layoutTextInPath({ |
| 170 | + text: longText, |
| 171 | + prepared, |
| 172 | + path: cm.pathCircle(200, 200, 150), |
| 173 | +}); |
| 174 | +``` |
| 175 | + |
| 176 | +## How it works |
| 177 | + |
| 178 | +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! |
| 179 | + |
| 180 | +## _cm_.prepare(_text_, _options_) {#cm-prepare} |
| 181 | + |
| 182 | +Builds a Pretext **prepared** value with the specified options. |
| 183 | + |
| 184 | +- **fontSize** — default `16`. |
| 185 | +- **fontFamily** — default `"Inter"`. |
| 186 | +- **fontStyle** — default `"normal"`. |
| 187 | +- **fontVariant** — default `"normal"`. |
| 188 | +- **fontWeight** — default `"normal"`. |
| 189 | +- Any extra keys are forwarded to Pretext’s [`prepareWithSegments`](https://github.com/chenglou/pretext). |
| 190 | + |
| 191 | +The return value includes Pretext’s fields plus `fontSize`, `fontFamily`, `fontStyle`, `fontVariant`, and `fontWeight` for convenience. |
| 192 | + |
| 193 | +```js |
| 194 | +const prepared = cm.prepare("Measure me", { |
| 195 | + fontSize: 16, |
| 196 | + fontFamily: "Inter", |
| 197 | +}); |
| 198 | +``` |
| 199 | + |
| 200 | +## _cm_.layoutTextInPath(_options_) {#cm-layout-text-in-path} |
| 201 | + |
| 202 | +Computes text positions with the specified options. |
| 203 | + |
| 204 | +- **text** — source string (required). |
| 205 | +- **path** — closed SVG path `d` (required). |
| 206 | +- **fontSize** — default `16` (same as [`prepare`](#cm-prepare)). |
| 207 | +- **fontFamily** — default `"Inter"`. |
| 208 | +- **fontStyle** — default `"normal"`. |
| 209 | +- **fontVariant** — default `"normal"`. |
| 210 | +- **fontWeight** — default `"normal"`. |
| 211 | +- **prepared** — optional Pretext prepared object from `prepare`. |
| 212 | +- **lineHeight** — spacing between lines; default `fontSize * 1.5`. |
| 213 | +- **angle** — rotation of lines in degrees; default `0`. |
| 214 | +- **repeat** — whether to loop the text to fill the shape; default `true`. |
| 215 | + |
| 216 | +Returns an object with: |
| 217 | + |
| 218 | +- **texts** — array of fragments. |
| 219 | +- **lines** — array of segments `[[x1, y1], [x2, y2]]`. |
| 220 | +- **path** — the input `d` string. |
| 221 | +- **fontSize** — effective font size used for the layout. |
| 222 | +- **fontFamily** — effective font family. |
| 223 | +- **fontStyle** — effective font style. |
| 224 | +- **fontVariant** — effective font variant. |
| 225 | +- **fontWeight** — effective font weight. |
| 226 | + |
| 227 | +## _cm_.clearPrepareCache() {#cm-clear-prepare-cache} |
| 228 | + |
| 229 | +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. |
| 230 | + |
| 231 | +```js |
| 232 | +cm.clearPrepareCache(); |
| 233 | +``` |
| 234 | + |
| 235 | +```js eval code=false inspector=false |
| 236 | +function renderPretext({ |
| 237 | + texts, |
| 238 | + lines, |
| 239 | + width = 400, |
| 240 | + height = 400, |
| 241 | + fontSize = 16, |
| 242 | + fontFamily = "Inter", |
| 243 | + fontStyle = "normal", |
| 244 | + fontVariant = "normal", |
| 245 | + fontWeight = "normal", |
| 246 | + path, |
| 247 | +}) { |
| 248 | + return cm.svg`<svg ${{ |
| 249 | + width, |
| 250 | + height, |
| 251 | + viewBox: `0 0 ${width} ${height}`, |
| 252 | + overflow: "visible", |
| 253 | + }}> |
| 254 | + <path ${{ |
| 255 | + d: path, |
| 256 | + fill: "none", |
| 257 | + stroke: "black", |
| 258 | + stroke_width: 1, |
| 259 | + }}/> |
| 260 | + <text ${{ |
| 261 | + data: texts, |
| 262 | + text_anchor: "middle", |
| 263 | + dominant_baseline: "central", |
| 264 | + font_size: `${fontSize}px`, |
| 265 | + font_family: fontFamily, |
| 266 | + font_style: fontStyle, |
| 267 | + font_variant: fontVariant, |
| 268 | + font_weight: fontWeight, |
| 269 | + transform: (text) => `translate(${text.x}, ${text.y}) rotate(${text.angle})`, |
| 270 | + textContent: (text) => text.text, |
| 271 | + }}/> |
| 272 | + </svg>`; |
| 273 | +} |
| 274 | +``` |
| 275 | + |
| 276 | +```js eval code=false inspector=false |
| 277 | +function renderPretextWithCanvas({ |
| 278 | + texts, |
| 279 | + lines, |
| 280 | + width = 400, |
| 281 | + height = 400, |
| 282 | + fontSize = 16, |
| 283 | + fontFamily = "Inter", |
| 284 | + fontStyle = "normal", |
| 285 | + fontVariant = "normal", |
| 286 | + fontWeight = "normal", |
| 287 | + path, |
| 288 | +}) { |
| 289 | + const context = cm.context2d({width, height}); |
| 290 | + context.fillStyle = "#222"; |
| 291 | + context.font = `${fontSize}px ${fontFamily}`; |
| 292 | + context.textAlign = "center"; |
| 293 | + context.textBaseline = "middle"; |
| 294 | + |
| 295 | + context.stroke(new Path2D(path)); |
| 296 | + |
| 297 | + for (const t of texts) { |
| 298 | + context.save(); |
| 299 | + context.translate(t.x, t.y); |
| 300 | + context.rotate((t.angle * Math.PI) / 180); |
| 301 | + context.fillText(t.text, 0, 0); |
| 302 | + context.restore(); |
| 303 | + } |
| 304 | + |
| 305 | + return context.canvas; |
| 306 | +} |
| 307 | +``` |
0 commit comments