@simple-mark/canvas 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +28 -2
  3. package/index.d.ts +119 -0
  4. package/index.js +421 -0
  5. package/package.json +27 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 one-prime
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,29 @@
1
- # Temporary Holding Version
1
+ # @simple-mark/canvas
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Draws Markdown on a `<canvas>` the way the [SimpleMark](https://github.com/one-prime/simple-mark)
4
+ editor shows it: syntax visible and dimmed, headings bigger, code and tables in monospace
5
+ (Korean, Chinese and Japanese exactly two columns wide), images and `$$` math below their
6
+ lines. For canvas-based apps (diagram and layout tools) that show Markdown text boxes.
7
+
8
+ ```ts
9
+ import { createMathImages, layoutMarkdown, paintMarkdown } from '@simple-mark/canvas';
10
+
11
+ const measure = (text: string, font: string) => ((ctx.font = font), ctx.measureText(text).width);
12
+ const math = createMathImages(() => redraw());
13
+
14
+ const layout = layoutMarkdown('# Room 3\n\n**Locked** until the key is found.', {
15
+ width: 300, // Infinity: keep the text's own lines
16
+ style: { fontFamily: 'sans-serif', fontSize: 16, fontWeight: 400, lineHeight: 1.5, color: '#222' },
17
+ measure,
18
+ imageSize: (src) => sizeOf(src), // natural size; undefined while loading
19
+ mathSize: (tex, family) => math.size(tex, family),
20
+ });
21
+ paintMarkdown(ctx, layout, { image: (src) => loaded(src), math: (tex, color) => math.image(tex, color) }, '#222');
22
+ // layout.width / layout.height: the box the text needs.
23
+ ```
24
+
25
+ - `layoutMarkdown` has no DOM dependency: text widths come from your `measure`.
26
+ - Math is rendered by `@simple-mark/math` (MathJax, loaded the first time it's needed) and
27
+ drawn as an image in the text's color.
28
+
29
+ MIT
package/index.d.ts ADDED
@@ -0,0 +1,119 @@
1
+ import { Theme } from "@simple-mark/model";
2
+ //#region packages/canvas/src/layout.d.ts
3
+ interface TextStyle {
4
+ fontFamily: string;
5
+ fontSize: number;
6
+ /** 100–900, as in CSS. */
7
+ fontWeight: number;
8
+ /** Line spacing as a multiple of the font size. */
9
+ lineHeight: number;
10
+ /** Color of text that has no style of its own. */
11
+ color: string;
12
+ textAlign?: 'left' | 'center' | 'right';
13
+ }
14
+ interface Size {
15
+ width: number;
16
+ height: number;
17
+ }
18
+ interface LayoutOptions {
19
+ /** Wrap width; Infinity keeps the text's own lines (automatic width). */
20
+ width: number;
21
+ style: TextStyle;
22
+ theme?: Theme;
23
+ /** Width of `text` drawn in `font` (a CSS font string, as for `ctx.font`). */
24
+ measure: (text: string, font: string) => number;
25
+ /** Natural size of an image (path as written): null if it can't load, undefined while loading. */
26
+ imageSize?: (src: string) => Size | null | undefined;
27
+ /**
28
+ * Size of rendered display math in em of `fontFamily` (see `createMathImages`): null if it
29
+ * has an error, undefined while rendering.
30
+ */
31
+ mathSize?: (tex: string, fontFamily: string) => Size | null | undefined;
32
+ }
33
+ /** A piece of text in one style. `top` is the top of its line box; text is drawn from `textTop`. */
34
+ interface TextRun {
35
+ text: string;
36
+ x: number;
37
+ width: number;
38
+ top: number;
39
+ height: number;
40
+ textTop: number;
41
+ font: string;
42
+ fontSize: number;
43
+ color: string;
44
+ /** Behind this text only (inline code). */
45
+ background?: string;
46
+ strike?: boolean;
47
+ underline?: boolean;
48
+ /** Draw character by character on a grid of this column width (tables), wide ones in two columns. */
49
+ column?: number;
50
+ }
51
+ /** A line's full-width background or bar (code blocks, table rows, quotes). */
52
+ interface LineDecoration {
53
+ top: number;
54
+ height: number;
55
+ background?: string;
56
+ /** A bar along the left edge: its color and width. */
57
+ bar?: {
58
+ color: string;
59
+ width: number;
60
+ };
61
+ }
62
+ interface ImageBox {
63
+ kind: 'image';
64
+ x: number;
65
+ y: number;
66
+ width: number;
67
+ height: number;
68
+ /** Path as written; null when a reference has no definition. */
69
+ src: string | null;
70
+ alt: string;
71
+ raw: string;
72
+ status: 'ready' | 'loading' | 'missing';
73
+ }
74
+ interface MathBox {
75
+ kind: 'math';
76
+ x: number;
77
+ y: number;
78
+ width: number;
79
+ height: number;
80
+ tex: string;
81
+ status: 'ready' | 'loading' | 'error';
82
+ }
83
+ interface MarkdownLayout {
84
+ width: number;
85
+ height: number;
86
+ runs: TextRun[];
87
+ lines: LineDecoration[];
88
+ boxes: (ImageBox | MathBox)[];
89
+ /** For placeholders and messages: the plain text font and colors. */
90
+ message: {
91
+ font: string;
92
+ dim: string;
93
+ error: string;
94
+ };
95
+ }
96
+ export declare function layoutMarkdown(source: string, options: LayoutOptions): MarkdownLayout;
97
+ //#endregion
98
+ //#region packages/canvas/src/math-images.d.ts
99
+ interface MathImages {
100
+ /**
101
+ * Size in em of the text's font (`fontFamily`, whose x-height MathJax's ex units follow);
102
+ * null if the TeX has an error; undefined while rendering (starts it).
103
+ */
104
+ size(tex: string, fontFamily: string): Size | null | undefined;
105
+ /** The formula drawn in `color`, its error message, or undefined while it is being made. */
106
+ image(tex: string, color: string): CanvasImageSource | string | undefined;
107
+ }
108
+ export declare function createMathImages(onReady: () => void): MathImages;
109
+ //#endregion
110
+ //#region packages/canvas/src/paint.d.ts
111
+ interface PaintSources {
112
+ /** A loaded image for a path as written; null or undefined while it can't be drawn. */
113
+ image?: (src: string) => CanvasImageSource | null | undefined;
114
+ /** Display math as an image in `color`, or its error message (see `createMathImages`). */
115
+ math?: (tex: string, color: string) => CanvasImageSource | string | null | undefined;
116
+ }
117
+ export declare function paintMarkdown(ctx: CanvasRenderingContext2D, layout: MarkdownLayout, sources?: PaintSources, color?: string): void;
118
+ //#endregion
119
+ export type { ImageBox, LayoutOptions, LineDecoration, MarkdownLayout, MathBox, MathImages, PaintSources, Size, TextRun, TextStyle };
package/index.js ADDED
@@ -0,0 +1,421 @@
1
+ import { charWidth, defaultTheme, markdownParser, markdownStyles, previewBlocks, resolveLineLook, resolveLook } from "@simple-mark/model";
2
+ //#region packages/canvas/src/layout.ts
3
+ /** Editor sizes in em, so a canvas text at any font size looks like the editor at 15px. */
4
+ const EM = {
5
+ previewTop: 4 / 15,
6
+ previewBottom: 8 / 15,
7
+ imageMaxHeight: 320 / 15,
8
+ imageGap: 8 / 15,
9
+ quoteBar: 3 / 15,
10
+ quoteIndent: 12 / 15
11
+ };
12
+ const ERROR_COLOR = "#cf222e";
13
+ /** Characters a line may break between, like CJK in the browser (no spaces needed). */
14
+ const BREAK_ANYWHERE = /[ᄀ-ᇿ⺀-鿿ꥠ-꥿가-힯豈-﫿＀-￯]/;
15
+ function fontOf(look, style, theme, scale) {
16
+ const family = look.monospace ? theme.monospaceFamily : style.fontFamily;
17
+ return `${look.italic ? "italic " : ""}${look.fontWeight ?? style.fontWeight} ${style.fontSize * scale}px ${family}`;
18
+ }
19
+ /** Words, spaces, and single characters that may break anywhere. */
20
+ function tokens(text) {
21
+ const out = [];
22
+ let word = "";
23
+ for (const ch of text) {
24
+ const space = ch === " " || ch === " ";
25
+ if (BREAK_ANYWHERE.test(ch)) {
26
+ if (word) out.push(word);
27
+ out.push(ch);
28
+ word = "";
29
+ } else if (space !== (word.length > 0 && (word[0] === " " || word[0] === " "))) {
30
+ if (word) out.push(word);
31
+ word = ch;
32
+ } else word += ch;
33
+ }
34
+ if (word) out.push(word);
35
+ return out;
36
+ }
37
+ function layoutMarkdown(source, options) {
38
+ const theme = options.theme ?? defaultTheme;
39
+ const { style, measure } = options;
40
+ const text = source.replace(/\r\n?/g, "\n");
41
+ const tree = markdownParser.parse(text);
42
+ const { blocks, spans } = markdownStyles(tree);
43
+ const lineStarts = [0];
44
+ for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1)) lineStarts.push(i + 1);
45
+ const isLineStart = new Set(lineStarts);
46
+ const lineEndOf = (n) => n + 1 < lineStarts.length ? lineStarts[n + 1] - 1 : text.length;
47
+ const lineOf = (pos) => {
48
+ let lo = 0;
49
+ let hi = lineStarts.length - 1;
50
+ while (lo < hi) {
51
+ const mid = lo + hi + 1 >> 1;
52
+ if (lineStarts[mid] <= pos) lo = mid;
53
+ else hi = mid - 1;
54
+ }
55
+ return lo;
56
+ };
57
+ const lineBlocks = lineStarts.map(() => /* @__PURE__ */ new Set());
58
+ for (const block of blocks) {
59
+ let end = block.to;
60
+ if (end > block.from && isLineStart.has(end)) end--;
61
+ for (let n = lineOf(block.from); n <= lineOf(end); n++) lineBlocks[n].add(block.style);
62
+ }
63
+ const previews = /* @__PURE__ */ new Map();
64
+ for (const preview of previewBlocks(tree, text)) {
65
+ const n = lineOf(preview.at);
66
+ previews.set(n, [...previews.get(n) ?? [], preview]);
67
+ }
68
+ const wrapWidth = options.width;
69
+ const runs = [];
70
+ /** The inline styles of each run (pieces join a run only when they come from the same span). */
71
+ const runStyles = [];
72
+ const lines = [];
73
+ const boxes = [];
74
+ /** Visual lines, for alignment: [first run, last run, width]. */
75
+ const rows = [];
76
+ let y = 0;
77
+ let maxWidth = 0;
78
+ let spanIndex = 0;
79
+ for (let n = 0; n < lineStarts.length; n++) {
80
+ const from = lineStarts[n];
81
+ const to = lineEndOf(n);
82
+ const styles = [...lineBlocks[n]];
83
+ const lineLook = resolveLineLook(styles, theme);
84
+ const scale = lineLook.fontScale ?? 1;
85
+ const lineHeight = style.lineHeight * style.fontSize * scale;
86
+ const textTop = (lineHeight - style.fontSize * scale) / 2;
87
+ const indent = lineLook.borderLeft ? EM.quoteIndent * style.fontSize : 0;
88
+ const table = styles.includes("table");
89
+ const available = wrapWidth - indent;
90
+ const pieces = [];
91
+ const pushText = (part, inline) => {
92
+ if (!part) return;
93
+ const font = fontOf(resolveLook(styles, inline, theme), style, theme, scale);
94
+ const column = table ? measure("0", font) : 0;
95
+ for (const token of tokens(part)) {
96
+ let width = 0;
97
+ if (table) for (const ch of token) width += charWidth(ch.codePointAt(0)) * column;
98
+ else width = measure(token, font);
99
+ pieces.push({
100
+ text: token,
101
+ width,
102
+ styles: inline
103
+ });
104
+ }
105
+ };
106
+ while (spanIndex < spans.length && spans[spanIndex].to <= from) spanIndex++;
107
+ let pos = from;
108
+ for (let k = spanIndex; k < spans.length && spans[k].from < to; k++) {
109
+ const span = spans[k];
110
+ pushText(text.slice(pos, Math.max(pos, span.from)), []);
111
+ pushText(text.slice(Math.max(pos, span.from), Math.min(span.to, to)), span.styles);
112
+ pos = Math.min(span.to, to);
113
+ }
114
+ pushText(text.slice(pos, to), []);
115
+ const visual = [[]];
116
+ let x = 0;
117
+ const newLine = () => {
118
+ visual.push([]);
119
+ x = 0;
120
+ };
121
+ for (const piece of pieces) {
122
+ const isSpace = /^[ \t]+$/.test(piece.text);
123
+ if (x + piece.width > available && x > 0) {
124
+ if (isSpace) {
125
+ newLine();
126
+ continue;
127
+ }
128
+ newLine();
129
+ }
130
+ if (piece.width > available && piece.text.length > 1 && !isSpace) {
131
+ const chars = Array.from(piece.text);
132
+ const each = piece.width / chars.length;
133
+ let part = "";
134
+ for (const ch of chars) {
135
+ if (x + (part.length + 1) * each > available && (x > 0 || part)) {
136
+ if (part) visual.at(-1).push({
137
+ text: part,
138
+ width: Array.from(part).length * each,
139
+ styles: piece.styles
140
+ });
141
+ newLine();
142
+ part = "";
143
+ }
144
+ part += ch;
145
+ }
146
+ if (part) {
147
+ const width = Array.from(part).length * each;
148
+ visual.at(-1).push({
149
+ text: part,
150
+ width,
151
+ styles: piece.styles
152
+ });
153
+ x += width;
154
+ }
155
+ continue;
156
+ }
157
+ visual.at(-1).push(piece);
158
+ x += piece.width;
159
+ }
160
+ for (const row of visual) {
161
+ const first = runs.length;
162
+ let rx = indent;
163
+ for (const piece of row) {
164
+ const last = runs.length > first ? runs.at(-1) : null;
165
+ if (last && runStyles.at(-1) === piece.styles) {
166
+ last.text += piece.text;
167
+ last.width += piece.width;
168
+ } else {
169
+ const look = resolveLook(styles, piece.styles, theme);
170
+ const font = fontOf(look, style, theme, scale);
171
+ runs.push({
172
+ text: piece.text,
173
+ x: rx,
174
+ width: piece.width,
175
+ top: y,
176
+ height: lineHeight,
177
+ textTop: y + textTop,
178
+ font,
179
+ fontSize: style.fontSize * scale,
180
+ color: look.color ?? style.color,
181
+ ...look.background ? { background: look.background } : {},
182
+ ...look.strike ? { strike: true } : {},
183
+ ...look.underline ? { underline: true } : {},
184
+ ...table ? { column: measure("0", font) } : {}
185
+ });
186
+ runStyles.push(piece.styles);
187
+ }
188
+ rx += piece.width;
189
+ }
190
+ rows.push([
191
+ first,
192
+ runs.length,
193
+ rx
194
+ ]);
195
+ maxWidth = Math.max(maxWidth, rx);
196
+ if (lineLook.lineBackground || lineLook.borderLeft) lines.push({
197
+ top: y,
198
+ height: lineHeight,
199
+ ...lineLook.lineBackground ? { background: lineLook.lineBackground } : {},
200
+ ...lineLook.borderLeft ? { bar: {
201
+ color: lineLook.borderLeft,
202
+ width: EM.quoteBar * style.fontSize
203
+ } } : {}
204
+ });
205
+ y += lineHeight;
206
+ }
207
+ for (const preview of previews.get(n) ?? []) {
208
+ const top = y + EM.previewTop * style.fontSize;
209
+ if ("tex" in preview) {
210
+ const size = options.mathSize?.(preview.tex, style.fontFamily);
211
+ const width = size ? size.width * style.fontSize : 0;
212
+ const height = size ? size.height * style.fontSize : size === null ? style.lineHeight * style.fontSize : 3 * style.lineHeight * style.fontSize;
213
+ boxes.push({
214
+ kind: "math",
215
+ x: 0,
216
+ y: top,
217
+ width,
218
+ height,
219
+ tex: preview.tex,
220
+ status: size ? "ready" : size === null ? "error" : "loading"
221
+ });
222
+ maxWidth = Math.max(maxWidth, width);
223
+ y = top + height + EM.previewBottom * style.fontSize;
224
+ continue;
225
+ }
226
+ const gap = EM.imageGap * style.fontSize;
227
+ const maxHeight = EM.imageMaxHeight * style.fontSize;
228
+ let rowX = 0;
229
+ let rowHeight = 0;
230
+ let rowTop = top;
231
+ for (const image of preview.images) {
232
+ const natural = image.src === null ? null : options.imageSize?.(image.src);
233
+ let width;
234
+ let height;
235
+ if (natural) {
236
+ const scale = Math.min(1, maxHeight / natural.height, wrapWidth === Infinity ? 1 : wrapWidth / natural.width);
237
+ width = natural.width * scale;
238
+ height = natural.height * scale;
239
+ } else {
240
+ width = wrapWidth === Infinity ? 10 * style.fontSize : wrapWidth;
241
+ height = natural === null ? style.lineHeight * style.fontSize : maxHeight / 2;
242
+ }
243
+ if (rowX > 0 && rowX + gap + width > wrapWidth) {
244
+ rowTop += rowHeight + gap;
245
+ rowX = 0;
246
+ rowHeight = 0;
247
+ }
248
+ const x = rowX + (rowX > 0 ? gap : 0);
249
+ boxes.push({
250
+ kind: "image",
251
+ x,
252
+ y: rowTop,
253
+ width,
254
+ height,
255
+ src: image.src,
256
+ alt: image.alt,
257
+ raw: image.raw,
258
+ status: natural ? "ready" : natural === null ? "missing" : "loading"
259
+ });
260
+ rowX = x + width;
261
+ rowHeight = Math.max(rowHeight, height);
262
+ maxWidth = Math.max(maxWidth, rowX);
263
+ }
264
+ y = rowTop + rowHeight + EM.previewBottom * style.fontSize;
265
+ }
266
+ }
267
+ const width = wrapWidth === Infinity ? maxWidth : wrapWidth;
268
+ const align = style.textAlign ?? "left";
269
+ if (align !== "left") for (const [first, last, rowWidth] of rows) {
270
+ const shift = align === "center" ? (width - rowWidth) / 2 : width - rowWidth;
271
+ for (let i = first; i < last; i++) runs[i].x += shift;
272
+ }
273
+ for (const box of boxes) if (box.kind === "math") box.x = (width - box.width) / 2;
274
+ const message = {
275
+ font: fontOf({}, style, theme, .85),
276
+ dim: theme.inline.mark.color ?? "#8c959f",
277
+ error: ERROR_COLOR
278
+ };
279
+ return {
280
+ width,
281
+ height: y,
282
+ runs,
283
+ lines,
284
+ boxes,
285
+ message
286
+ };
287
+ }
288
+ //#endregion
289
+ //#region packages/canvas/src/math-images.ts
290
+ /** CSS ex as a share of the font size when it can't be measured. */
291
+ const DEFAULT_EX = .5;
292
+ /** Pixels per em of the images, so they stay sharp when a canvas is zoomed in. */
293
+ const PIXELS_PER_EM = 64;
294
+ const exRatios = /* @__PURE__ */ new Map();
295
+ /** The x-height of a font as a share of its size: what CSS (and MathJax) call 1ex. */
296
+ function exRatio(fontFamily) {
297
+ let ratio = exRatios.get(fontFamily);
298
+ if (ratio === void 0) {
299
+ const ctx = typeof document === "undefined" ? null : document.createElement("canvas").getContext("2d");
300
+ if (ctx) ctx.font = `100px ${fontFamily}`;
301
+ ratio = (ctx ? ctx.measureText("x").actualBoundingBoxAscent / 100 : 0) || DEFAULT_EX;
302
+ exRatios.set(fontFamily, ratio);
303
+ }
304
+ return ratio;
305
+ }
306
+ function createMathImages(onReady) {
307
+ let loading;
308
+ const rendered = /* @__PURE__ */ new Map();
309
+ const images = /* @__PURE__ */ new Map();
310
+ function render(tex) {
311
+ rendered.set(tex, "rendering");
312
+ loading ??= import("@simple-mark/math");
313
+ loading.then((m) => m.renderMath(tex, true)).then((result) => {
314
+ if (result.svg === void 0) rendered.set(tex, { error: result.error });
315
+ else {
316
+ const ex = (name) => parseFloat(new RegExp(`${name}="([\\d.]+)ex"`).exec(result.svg)?.[1] ?? "0");
317
+ rendered.set(tex, {
318
+ svg: result.svg,
319
+ ex: {
320
+ width: ex("width"),
321
+ height: ex("height")
322
+ }
323
+ });
324
+ }
325
+ onReady();
326
+ });
327
+ }
328
+ return {
329
+ size(tex, fontFamily) {
330
+ const entry = rendered.get(tex);
331
+ if (entry === void 0) render(tex);
332
+ if (entry === void 0 || entry === "rendering") return void 0;
333
+ if ("error" in entry) return null;
334
+ const ratio = exRatio(fontFamily);
335
+ return {
336
+ width: entry.ex.width * ratio,
337
+ height: entry.ex.height * ratio
338
+ };
339
+ },
340
+ image(tex, color) {
341
+ const entry = rendered.get(tex);
342
+ if (entry === void 0 || entry === "rendering") return void 0;
343
+ if ("error" in entry) return entry.error;
344
+ const key = `${color}\n${tex}`;
345
+ const known = images.get(key);
346
+ if (known instanceof HTMLImageElement) return known;
347
+ if (known === "loading") return void 0;
348
+ const svg = entry.svg.replace(/currentColor/g, color).replace(/width="[\d.]+ex"/, `width="${entry.ex.width * DEFAULT_EX * PIXELS_PER_EM}px"`).replace(/height="[\d.]+ex"/, `height="${entry.ex.height * DEFAULT_EX * PIXELS_PER_EM}px"`);
349
+ const img = new Image();
350
+ images.set(key, "loading");
351
+ img.onload = () => {
352
+ images.set(key, img);
353
+ onReady();
354
+ };
355
+ img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
356
+ }
357
+ };
358
+ }
359
+ //#endregion
360
+ //#region packages/canvas/src/paint.ts
361
+ const PLACEHOLDER = "rgba(129, 139, 152, 0.15)";
362
+ function paintMarkdown(ctx, layout, sources = {}, color = "#1f2328") {
363
+ ctx.save();
364
+ ctx.textBaseline = "top";
365
+ ctx.textAlign = "left";
366
+ for (const line of layout.lines) {
367
+ if (line.background) {
368
+ ctx.fillStyle = line.background;
369
+ ctx.fillRect(0, line.top, layout.width, line.height);
370
+ }
371
+ if (line.bar) {
372
+ ctx.fillStyle = line.bar.color;
373
+ ctx.fillRect(0, line.top, line.bar.width, line.height);
374
+ }
375
+ }
376
+ for (const run of layout.runs) {
377
+ ctx.font = run.font;
378
+ if (run.background) {
379
+ ctx.fillStyle = run.background;
380
+ ctx.fillRect(run.x, run.textTop - run.fontSize * .1, run.width, run.fontSize * 1.2);
381
+ }
382
+ ctx.fillStyle = run.color;
383
+ if (run.column) {
384
+ let x = run.x;
385
+ for (const ch of run.text) {
386
+ const cell = charWidth(ch.codePointAt(0)) * run.column;
387
+ ctx.fillText(ch, x + (cell - ctx.measureText(ch).width) / 2, run.textTop);
388
+ x += cell;
389
+ }
390
+ } else ctx.fillText(run.text, run.x, run.textTop);
391
+ if (run.strike || run.underline) {
392
+ ctx.fillStyle = run.color;
393
+ const thickness = Math.max(run.fontSize / 15, .5);
394
+ if (run.strike) ctx.fillRect(run.x, run.textTop + run.fontSize * .55, run.width, thickness);
395
+ if (run.underline) ctx.fillRect(run.x, run.textTop + run.fontSize * 1.02, run.width, thickness);
396
+ }
397
+ }
398
+ const message = (text, x, y, fill) => {
399
+ ctx.font = layout.message.font;
400
+ ctx.fillStyle = fill;
401
+ ctx.fillText(text, x, y);
402
+ };
403
+ for (const box of layout.boxes) if (box.kind === "image") {
404
+ const image = box.status === "ready" && box.src !== null ? sources.image?.(box.src) : null;
405
+ if (image) ctx.drawImage(image, box.x, box.y, box.width, box.height);
406
+ else if (box.status === "missing") message(box.src === null ? `No definition for ${box.raw}` : `Image not found: ${box.raw}`, box.x, box.y, layout.message.error);
407
+ else {
408
+ ctx.fillStyle = PLACEHOLDER;
409
+ ctx.fillRect(box.x, box.y, box.width, box.height);
410
+ if (box.alt) message(box.alt, box.x + 6, box.y + 6, layout.message.dim);
411
+ }
412
+ } else {
413
+ const math = sources.math?.(box.tex, color);
414
+ if (typeof math === "string") message(`Math error: ${math}`, box.x, box.y, layout.message.error);
415
+ else if (math && box.status === "ready") ctx.drawImage(math, box.x, box.y, box.width, box.height);
416
+ else if (box.status === "error") message("Math error", box.x, box.y, layout.message.error);
417
+ }
418
+ ctx.restore();
419
+ }
420
+ //#endregion
421
+ export { createMathImages, layoutMarkdown, paintMarkdown };
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
1
  {
2
2
  "name": "@simple-mark/canvas",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Draws Markdown on a <canvas> without hiding its syntax: layout (no DOM) and Canvas 2D painting.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/one-prime/simple-mark.git",
10
+ "directory": "packages/canvas"
11
+ },
12
+ "keywords": [
13
+ "markdown",
14
+ "wysiwyg",
15
+ "editor",
16
+ "gfm",
17
+ "canvas"
18
+ ],
19
+ "exports": {
20
+ ".": {
21
+ "types": "./index.d.ts",
22
+ "import": "./index.js"
23
+ }
24
+ },
25
+ "dependencies": {
26
+ "@simple-mark/math": "^0.2.0",
27
+ "@simple-mark/model": "^0.2.0"
28
+ }
29
+ }