ntk 7.7.0 → 8.0.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.
- package/README.md +9 -10
- package/lib/index.js +2 -29
- package/lib/text/layout.js +4 -4
- package/lib/widgets/svgview.js +5 -4
- package/package.json +4 -9
- package/lib/widgets/css.js +0 -673
- package/lib/widgets/highlight.js +0 -97
- package/lib/widgets/htmlview.js +0 -893
- package/lib/widgets/markdown.js +0 -148
- package/lib/widgets/markdownview.js +0 -492
- package/lib/widgets/tex.js +0 -679
- package/lib/yoga.js +0 -119
package/lib/widgets/markdown.js
DELETED
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
// Markdown parsing for MarkdownView — a thin adapter over the `marked`
|
|
2
|
-
// lexer (CommonMark + GFM), mapping its token stream onto the small block /
|
|
3
|
-
// inline AST the widget renders. The parser used to be hand-rolled here;
|
|
4
|
-
// it was replaced after subtle divergences from CommonMark surfaced
|
|
5
|
-
// (e.g. intra-word `WM_DELETE_WINDOW` sprouting emphasis).
|
|
6
|
-
//
|
|
7
|
-
// block: { type: 'heading', level, children }
|
|
8
|
-
// | { type: 'paragraph', children }
|
|
9
|
-
// | { type: 'code', text, lang }
|
|
10
|
-
// | { type: 'blockquote', blocks }
|
|
11
|
-
// | { type: 'list', ordered, start, items: [ blocks ] }
|
|
12
|
-
// | { type: 'hr' }
|
|
13
|
-
// | { type: 'table', align: ['left'|'center'|'right'],
|
|
14
|
-
// header: [ inline[] ], rows: [ [ inline[] ] ] }
|
|
15
|
-
// inline: { type: 'text', text }
|
|
16
|
-
// | { type: 'strong'|'em', children }
|
|
17
|
-
// | { type: 'code', text }
|
|
18
|
-
// | { type: 'link', children, href }
|
|
19
|
-
|
|
20
|
-
import { Lexer } from 'marked';
|
|
21
|
-
|
|
22
|
-
export function parseMarkdown(src) {
|
|
23
|
-
return mapBlocks(new Lexer({ gfm: true }).lex(String(src ?? '')));
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** inline-only parsing (exported for tests / embedders) */
|
|
27
|
-
export function parseInline(text) {
|
|
28
|
-
return mapInline(Lexer.lexInline(String(text ?? '')));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function mapBlocks(tokens) {
|
|
32
|
-
const blocks = [];
|
|
33
|
-
for (const t of tokens) {
|
|
34
|
-
switch (t.type) {
|
|
35
|
-
case 'heading':
|
|
36
|
-
blocks.push({ type: 'heading', level: t.depth, children: mapInline(t.tokens) });
|
|
37
|
-
break;
|
|
38
|
-
case 'paragraph':
|
|
39
|
-
// tight list items carry their inline content in a block-level 'text'
|
|
40
|
-
case 'text':
|
|
41
|
-
blocks.push({ type: 'paragraph', children: mapInline(t.tokens ?? [t]) });
|
|
42
|
-
break;
|
|
43
|
-
case 'code':
|
|
44
|
-
blocks.push({
|
|
45
|
-
type: 'code',
|
|
46
|
-
text: t.text,
|
|
47
|
-
lang: (t.lang || '').split(/\s+/)[0]
|
|
48
|
-
});
|
|
49
|
-
break;
|
|
50
|
-
case 'blockquote':
|
|
51
|
-
blocks.push({ type: 'blockquote', blocks: mapBlocks(t.tokens) });
|
|
52
|
-
break;
|
|
53
|
-
case 'list':
|
|
54
|
-
blocks.push({
|
|
55
|
-
type: 'list',
|
|
56
|
-
ordered: !!t.ordered,
|
|
57
|
-
start: typeof t.start === 'number' ? t.start : 1,
|
|
58
|
-
items: t.items.map((item) => mapBlocks(item.tokens))
|
|
59
|
-
});
|
|
60
|
-
break;
|
|
61
|
-
case 'hr':
|
|
62
|
-
blocks.push({ type: 'hr' });
|
|
63
|
-
break;
|
|
64
|
-
case 'table':
|
|
65
|
-
blocks.push({
|
|
66
|
-
type: 'table',
|
|
67
|
-
align: t.align.map((a) => a ?? 'left'),
|
|
68
|
-
header: t.header.map((cell) => mapInline(cell.tokens)),
|
|
69
|
-
rows: t.rows.map((row) => row.map((cell) => mapInline(cell.tokens)))
|
|
70
|
-
});
|
|
71
|
-
break;
|
|
72
|
-
case 'html':
|
|
73
|
-
blocks.push({ type: 'paragraph', children: [{ type: 'text', text: t.text.trim() }] });
|
|
74
|
-
break;
|
|
75
|
-
case 'space':
|
|
76
|
-
case 'def':
|
|
77
|
-
break;
|
|
78
|
-
default:
|
|
79
|
-
if (t.tokens) blocks.push({ type: 'paragraph', children: mapInline(t.tokens) });
|
|
80
|
-
else if (t.text) blocks.push({ type: 'paragraph', children: [{ type: 'text', text: t.text }] });
|
|
81
|
-
break;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return blocks;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function mapInline(tokens) {
|
|
88
|
-
const nodes = [];
|
|
89
|
-
const pushText = (text) => {
|
|
90
|
-
// soft line breaks inside a paragraph render as spaces (the layout
|
|
91
|
-
// engine treats \n as a hard break)
|
|
92
|
-
text = text.replace(/\n/g, ' ');
|
|
93
|
-
const last = nodes[nodes.length - 1];
|
|
94
|
-
if (last && last.type === 'text') last.text += text;
|
|
95
|
-
else nodes.push({ type: 'text', text });
|
|
96
|
-
};
|
|
97
|
-
for (const t of tokens ?? []) {
|
|
98
|
-
switch (t.type) {
|
|
99
|
-
case 'text':
|
|
100
|
-
if (t.tokens) nodes.push(...mapInline(t.tokens));
|
|
101
|
-
else pushText(t.escaped ? unescapeHtml(t.text) : t.text);
|
|
102
|
-
break;
|
|
103
|
-
case 'escape':
|
|
104
|
-
pushText(t.text);
|
|
105
|
-
break;
|
|
106
|
-
case 'strong':
|
|
107
|
-
nodes.push({ type: 'strong', children: mapInline(t.tokens) });
|
|
108
|
-
break;
|
|
109
|
-
case 'em':
|
|
110
|
-
nodes.push({ type: 'em', children: mapInline(t.tokens) });
|
|
111
|
-
break;
|
|
112
|
-
case 'del':
|
|
113
|
-
// no strikethrough decoration (yet): render the content plainly
|
|
114
|
-
nodes.push(...mapInline(t.tokens));
|
|
115
|
-
break;
|
|
116
|
-
case 'codespan':
|
|
117
|
-
nodes.push({ type: 'code', text: t.text });
|
|
118
|
-
break;
|
|
119
|
-
case 'link':
|
|
120
|
-
nodes.push({ type: 'link', children: mapInline(t.tokens), href: t.href });
|
|
121
|
-
break;
|
|
122
|
-
case 'image':
|
|
123
|
-
// no image rendering: alt text as a link to the source
|
|
124
|
-
nodes.push({
|
|
125
|
-
type: 'link',
|
|
126
|
-
children: [{ type: 'text', text: t.text || t.href }],
|
|
127
|
-
href: t.href
|
|
128
|
-
});
|
|
129
|
-
break;
|
|
130
|
-
case 'br':
|
|
131
|
-
pushText(' ');
|
|
132
|
-
break;
|
|
133
|
-
case 'html':
|
|
134
|
-
pushText(t.raw);
|
|
135
|
-
break;
|
|
136
|
-
default:
|
|
137
|
-
if (t.text) pushText(t.text);
|
|
138
|
-
break;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
return nodes;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const ENTITIES = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" };
|
|
145
|
-
|
|
146
|
-
function unescapeHtml(text) {
|
|
147
|
-
return text.replace(/&(?:amp|lt|gt|quot|#39);/g, (m) => ENTITIES[m]);
|
|
148
|
-
}
|
|
@@ -1,492 +0,0 @@
|
|
|
1
|
-
import { TextLayout } from '../text/layout.js';
|
|
2
|
-
import { tokenize } from './highlight.js';
|
|
3
|
-
import { parseMarkdown } from './markdown.js';
|
|
4
|
-
import { layoutTex } from './tex.js';
|
|
5
|
-
|
|
6
|
-
const MATH_LANGS = new Set(['math', 'tex', 'latex', 'katex']);
|
|
7
|
-
|
|
8
|
-
const DEFAULT_THEME = {
|
|
9
|
-
family: 'sans-serif',
|
|
10
|
-
monoFamily: 'monospace',
|
|
11
|
-
size: 16,
|
|
12
|
-
color: '#222222',
|
|
13
|
-
background: 'white',
|
|
14
|
-
lineHeight: 1.35,
|
|
15
|
-
headingScales: [2, 1.5, 1.25, 1.1, 1, 0.9],
|
|
16
|
-
linkColor: '#0b61c9',
|
|
17
|
-
codeColor: '#8f2b4f',
|
|
18
|
-
codeBg: '#f2f2f2',
|
|
19
|
-
quoteColor: '#555555',
|
|
20
|
-
quoteBar: '#d8d8d8',
|
|
21
|
-
hrColor: '#dddddd',
|
|
22
|
-
tableBorder: '#d8d8d8',
|
|
23
|
-
tableHeaderBg: '#f7f7f7',
|
|
24
|
-
blockSpacing: 0.7, // em (of base size) between blocks
|
|
25
|
-
padding: 16,
|
|
26
|
-
// fence syntax highlighting (lib/widgets/highlight.js token kinds);
|
|
27
|
-
// unlisted kinds (incl. 'plain') fall back to the base text color
|
|
28
|
-
codeTheme: {
|
|
29
|
-
keyword: '#a626a4',
|
|
30
|
-
literal: '#0184bc',
|
|
31
|
-
string: '#50a14f',
|
|
32
|
-
number: '#986801',
|
|
33
|
-
comment: '#a0a1a7',
|
|
34
|
-
tag: '#e45649',
|
|
35
|
-
attr: '#986801',
|
|
36
|
-
function: '#4078f2'
|
|
37
|
-
}
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* A widget that renders markdown into a window (or any 2d context target).
|
|
42
|
-
*
|
|
43
|
-
* All layout runs client-side through the text API (`FontManager` /
|
|
44
|
-
* `TextLayout`): heading/paragraph/list/quote/code blocks become styled
|
|
45
|
-
* spans, get shaped (kerning, fallback, bidi) and wrapped to the container
|
|
46
|
-
* width; drawing batches glyphs into minimal CompositeGlyphs requests.
|
|
47
|
-
*
|
|
48
|
-
* const view = new MarkdownView(wnd, { theme: { size: 15 } });
|
|
49
|
-
* view.setMarkdown('# Hello\n\nSome *markdown* here.');
|
|
50
|
-
* wnd.map();
|
|
51
|
-
*
|
|
52
|
-
* Standalone use (no window attach): `view.layout(width)` then
|
|
53
|
-
* `view.draw(ctx, x, y)`; `view.contentHeight` after layout. Layout is
|
|
54
|
-
* synchronous throughout — nothing here loads in the background.
|
|
55
|
-
*/
|
|
56
|
-
export default class MarkdownView {
|
|
57
|
-
constructor(window, opts = {}) {
|
|
58
|
-
this.window = window ?? null;
|
|
59
|
-
this._standaloneFonts = opts.fonts ?? null; // for headless layout without a window
|
|
60
|
-
this.theme = { ...DEFAULT_THEME, ...(opts.theme || {}) };
|
|
61
|
-
if (opts.theme?.codeTheme) {
|
|
62
|
-
this.theme.codeTheme = { ...DEFAULT_THEME.codeTheme, ...opts.theme.codeTheme };
|
|
63
|
-
}
|
|
64
|
-
if (opts.padding !== undefined) this.theme.padding = opts.padding;
|
|
65
|
-
this._blocks = [];
|
|
66
|
-
this._items = []; // placed draw items from the last layout()
|
|
67
|
-
this._links = []; // clickable link rects from the last draw()
|
|
68
|
-
this._layoutWidth = -1;
|
|
69
|
-
this.contentHeight = 0;
|
|
70
|
-
/** called with (href, event) when a rendered link is clicked (window
|
|
71
|
-
* mode wires mousedown automatically; standalone callers use linkAt) */
|
|
72
|
-
this.onLink = opts.onLink ?? null;
|
|
73
|
-
|
|
74
|
-
if (this.window) {
|
|
75
|
-
this._ctx = this.window.getContext('2d');
|
|
76
|
-
this.window.on('expose', () => this.render());
|
|
77
|
-
// a move is a ConfigureNotify too; re-laying out a document per step
|
|
78
|
-
// of a window drag is exactly what ev.resized is for
|
|
79
|
-
this.window.on('resize', (ev) => {
|
|
80
|
-
if (ev.resized) this._layoutWidth = -1; // invalidate
|
|
81
|
-
});
|
|
82
|
-
this.window.on('mousedown', (ev) => {
|
|
83
|
-
if (ev.keycode !== 1 || !this.onLink) return; // left button only
|
|
84
|
-
const href = this.linkAt(ev.x, ev.y);
|
|
85
|
-
if (href) this.onLink(href, ev);
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* The link href under a point, or null. Coordinates are in the same
|
|
92
|
-
* device space as the last `draw()` (window mode: window coordinates).
|
|
93
|
-
*/
|
|
94
|
-
linkAt(x, y) {
|
|
95
|
-
for (const l of this._links) {
|
|
96
|
-
if (x >= l.x && x < l.x + l.w && y >= l.y && y < l.y + l.h) return l.href;
|
|
97
|
-
}
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
setMarkdown(src) {
|
|
102
|
-
this._blocks = parseMarkdown(src);
|
|
103
|
-
this._layoutWidth = -1;
|
|
104
|
-
if (this.window && this.window._mapped) this.render();
|
|
105
|
-
return this;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** lay content out for a container width; returns total content height */
|
|
109
|
-
layout(width) {
|
|
110
|
-
const fonts = this._fonts();
|
|
111
|
-
this._items = [];
|
|
112
|
-
const t = this.theme;
|
|
113
|
-
const inner = Math.max(20, width - t.padding * 2);
|
|
114
|
-
const y = this._layoutBlocks(fonts, this._blocks, this._blockStyles(), t.padding, 0, inner);
|
|
115
|
-
this.contentHeight = y + t.padding * 2;
|
|
116
|
-
this._layoutWidth = width;
|
|
117
|
-
return this.contentHeight;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
_fonts() {
|
|
121
|
-
if (this.window) return this.window.app.fonts;
|
|
122
|
-
if (!this._standaloneFonts) {
|
|
123
|
-
throw new Error('MarkdownView without a window needs { fonts: new FontManager() }');
|
|
124
|
-
}
|
|
125
|
-
return this._standaloneFonts;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
_blockStyles() {
|
|
129
|
-
const t = this.theme;
|
|
130
|
-
return {
|
|
131
|
-
base: { family: t.family, size: t.size, color: t.color },
|
|
132
|
-
mono: { family: t.monoFamily, size: t.size * 0.9, color: t.codeColor }
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** recursive block layout; returns the y after the last block */
|
|
137
|
-
_layoutBlocks(fonts, blocks, styles, x, y, width) {
|
|
138
|
-
const t = this.theme;
|
|
139
|
-
const spacing = t.blockSpacing * t.size;
|
|
140
|
-
let first = true;
|
|
141
|
-
|
|
142
|
-
for (const block of blocks) {
|
|
143
|
-
if (!first) y += spacing;
|
|
144
|
-
first = false;
|
|
145
|
-
|
|
146
|
-
switch (block.type) {
|
|
147
|
-
case 'heading': {
|
|
148
|
-
const scale = t.headingScales[block.level - 1] ?? 1;
|
|
149
|
-
const style = {
|
|
150
|
-
...styles.base,
|
|
151
|
-
size: Math.round(t.size * scale),
|
|
152
|
-
weight: 700
|
|
153
|
-
};
|
|
154
|
-
y += block.level <= 2 ? spacing * 0.5 : 0;
|
|
155
|
-
const layout = new TextLayout(fonts, this._spans(block.children, style), style, {
|
|
156
|
-
maxWidth: width,
|
|
157
|
-
lineHeight: t.lineHeight
|
|
158
|
-
});
|
|
159
|
-
this._items.push({ kind: 'text', x, y, layout });
|
|
160
|
-
y += layout.height;
|
|
161
|
-
break;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
case 'paragraph': {
|
|
165
|
-
const style = styles.base;
|
|
166
|
-
const layout = new TextLayout(fonts, this._spans(block.children, style), style, {
|
|
167
|
-
maxWidth: width,
|
|
168
|
-
lineHeight: t.lineHeight
|
|
169
|
-
});
|
|
170
|
-
this._items.push({ kind: 'text', x, y, layout });
|
|
171
|
-
y += layout.height;
|
|
172
|
-
break;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
case 'code': {
|
|
176
|
-
// ```math / ```latex fences render as display-mode formulas
|
|
177
|
-
if (MATH_LANGS.has((block.lang || '').toLowerCase())) {
|
|
178
|
-
let box = null;
|
|
179
|
-
try {
|
|
180
|
-
// 1.21 mirrors katex.css's font-size on .katex in body text
|
|
181
|
-
box = layoutTex(block.text, {
|
|
182
|
-
size: t.size * 1.21,
|
|
183
|
-
displayMode: true,
|
|
184
|
-
color: t.color
|
|
185
|
-
});
|
|
186
|
-
} catch {
|
|
187
|
-
// KaTeX parse error: fall through to the plain code block
|
|
188
|
-
}
|
|
189
|
-
if (box) {
|
|
190
|
-
const bx = x + Math.max(0, (width - box.width) / 2);
|
|
191
|
-
this._items.push({ kind: 'tex', x: bx, y, box });
|
|
192
|
-
y += box.height;
|
|
193
|
-
break;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
const pad = t.size * 0.5;
|
|
197
|
-
const style = { ...styles.mono, color: t.color };
|
|
198
|
-
const spans = tokenize(block.text, block.lang).map((tok) => ({
|
|
199
|
-
text: tok.text,
|
|
200
|
-
color: t.codeTheme?.[tok.kind] ?? style.color
|
|
201
|
-
}));
|
|
202
|
-
const layout = new TextLayout(fonts, spans.length ? spans : '', style, {
|
|
203
|
-
maxWidth: width - pad * 2,
|
|
204
|
-
lineHeight: 1.25
|
|
205
|
-
});
|
|
206
|
-
this._items.push({
|
|
207
|
-
kind: 'rect',
|
|
208
|
-
x,
|
|
209
|
-
y,
|
|
210
|
-
width,
|
|
211
|
-
height: layout.height + pad * 2,
|
|
212
|
-
color: t.codeBg
|
|
213
|
-
});
|
|
214
|
-
this._items.push({ kind: 'text', x: x + pad, y: y + pad, layout });
|
|
215
|
-
y += layout.height + pad * 2;
|
|
216
|
-
break;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
case 'blockquote': {
|
|
220
|
-
const indent = t.size;
|
|
221
|
-
const startY = y;
|
|
222
|
-
const bar = { kind: 'rect', x, y, width: 3, height: 0, color: t.quoteBar };
|
|
223
|
-
this._items.push(bar);
|
|
224
|
-
const quoted = {
|
|
225
|
-
base: { ...styles.base, color: t.quoteColor },
|
|
226
|
-
mono: styles.mono
|
|
227
|
-
};
|
|
228
|
-
y = this._layoutBlocks(fonts, block.blocks, quoted, x + indent, y, width - indent);
|
|
229
|
-
bar.height = y - startY;
|
|
230
|
-
break;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
case 'list': {
|
|
234
|
-
const indent = t.size * 1.6;
|
|
235
|
-
let n = block.start;
|
|
236
|
-
for (let it = 0; it < block.items.length; it++) {
|
|
237
|
-
if (it > 0) y += spacing * 0.4;
|
|
238
|
-
const marker = block.ordered ? `${n++}.` : '•';
|
|
239
|
-
const markerLayout = new TextLayout(fonts, marker, styles.base, {});
|
|
240
|
-
this._items.push({ kind: 'text', x, y, layout: markerLayout });
|
|
241
|
-
y = this._layoutBlocks(fonts, block.items[it], styles, x + indent, y, width - indent);
|
|
242
|
-
}
|
|
243
|
-
break;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
case 'table': {
|
|
247
|
-
y = this._layoutTable(fonts, block, styles, x, y, width);
|
|
248
|
-
break;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
case 'hr': {
|
|
252
|
-
this._items.push({
|
|
253
|
-
kind: 'rect',
|
|
254
|
-
x,
|
|
255
|
-
y: y + spacing * 0.5,
|
|
256
|
-
width,
|
|
257
|
-
height: 1,
|
|
258
|
-
color: t.hrColor
|
|
259
|
-
});
|
|
260
|
-
y += spacing;
|
|
261
|
-
break;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
return y;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Table layout: columns take their natural (unwrapped) width when the
|
|
270
|
-
* table fits, otherwise shrink proportionally (with a floor) and cells
|
|
271
|
-
* wrap. The grid and header background are emitted as rect items, so the
|
|
272
|
-
* two-pass draw() paints them under the cell text.
|
|
273
|
-
*/
|
|
274
|
-
_layoutTable(fonts, block, styles, x, y, width) {
|
|
275
|
-
const t = this.theme;
|
|
276
|
-
const hpad = t.size * 0.55;
|
|
277
|
-
const vpad = t.size * 0.3;
|
|
278
|
-
const ncols = block.header.length;
|
|
279
|
-
const allRows = [block.header, ...block.rows];
|
|
280
|
-
|
|
281
|
-
// natural width per column: widest unwrapped cell
|
|
282
|
-
const cellStyle = (isHeader) => (isHeader ? { ...styles.base, weight: 700 } : styles.base);
|
|
283
|
-
const cellSpans = allRows.map((row, r) =>
|
|
284
|
-
Array.from({ length: ncols }, (_, c) => this._spans(row[c] ?? [], cellStyle(r === 0)))
|
|
285
|
-
);
|
|
286
|
-
const natural = new Array(ncols).fill(0);
|
|
287
|
-
for (const row of cellSpans) {
|
|
288
|
-
for (let c = 0; c < ncols; c++) {
|
|
289
|
-
if (!row[c].length) continue;
|
|
290
|
-
const measure = new TextLayout(fonts, row[c], styles.base, { lineHeight: t.lineHeight });
|
|
291
|
-
natural[c] = Math.max(natural[c], measure.width);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// Two widths per column from here on: `cols` is the outer width the grid
|
|
296
|
-
// is drawn at, `inner` is what a cell is laid out at. `inner` is kept
|
|
297
|
-
// alongside rather than derived as `cols[c] - hpad * 2`, and that is not
|
|
298
|
-
// tidiness. Adding a padding and subtracting it again is not the identity
|
|
299
|
-
// in binary floating point — for a natural width of 48.953125 at size 16
|
|
300
|
-
// the round trip returns 48.95312499999999 — and a token handed a maxWidth
|
|
301
|
-
// a few ULPs below its own measured width is *wider than its container*,
|
|
302
|
-
// so TextLayout force-breaks it. That is a `value` header rendered as
|
|
303
|
-
// `valu` over `e`, on some fonts and not others, depending on nothing more
|
|
304
|
-
// than whether the width happens to survive the round trip.
|
|
305
|
-
const cols = natural.map((w) => w + hpad * 2);
|
|
306
|
-
const inner = natural.slice();
|
|
307
|
-
const total = cols.reduce((a, b) => a + b, 0);
|
|
308
|
-
if (total > width) {
|
|
309
|
-
// Shrink towards each column's *min-content* width — the widest word it
|
|
310
|
-
// holds that cannot be broken — and never past it. The floor used to be
|
|
311
|
-
// a flat 2.5em guess with no relation to the content, so a column whose
|
|
312
|
-
// longest word was wider than that got a maxWidth below the word and the
|
|
313
|
-
// word came apart.
|
|
314
|
-
//
|
|
315
|
-
// Min-content is the widest whitespace-delimited token, each measured
|
|
316
|
-
// *unconstrained*. Laying the whole cell out at a tiny maxWidth looks
|
|
317
|
-
// like the obvious probe and is not one: a token wider than the
|
|
318
|
-
// container is force-broken rather than left to overflow whenever a
|
|
319
|
-
// single cluster fits, so the probe reports a fragment's width. It is
|
|
320
|
-
// not even monotonic in maxWidth — see the test that pins that. An
|
|
321
|
-
// unconstrained token cannot be broken at all.
|
|
322
|
-
//
|
|
323
|
-
// A token split across spans — `**val**ue` — is measured as its halves
|
|
324
|
-
// and so under-measured. Rare, and still far above the flat guess this
|
|
325
|
-
// replaced. Only measured here: a table that fits needs no minimums.
|
|
326
|
-
const content = new Array(ncols).fill(0);
|
|
327
|
-
for (const row of cellSpans) {
|
|
328
|
-
for (let c = 0; c < ncols; c++) {
|
|
329
|
-
for (const span of row[c]) {
|
|
330
|
-
for (const token of String(span.text ?? '').split(/\s+/)) {
|
|
331
|
-
if (!token) continue;
|
|
332
|
-
const measure = new TextLayout(fonts, [{ ...span, text: token }], styles.base, {
|
|
333
|
-
lineHeight: t.lineHeight
|
|
334
|
-
});
|
|
335
|
-
content[c] = Math.max(content[c], measure.width);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
const mins = content.map((w) => w + hpad * 2);
|
|
341
|
-
// Space above the minimums is shared in proportion to how much each
|
|
342
|
-
// column asked for, so a wide column gives up more than a narrow one.
|
|
343
|
-
// When the minimums themselves do not fit, the table overflows rather
|
|
344
|
-
// than breaking words — which is what a browser does with the same
|
|
345
|
-
// content, and is the lesser of the two evils.
|
|
346
|
-
const slack = width - mins.reduce((a, b) => a + b, 0);
|
|
347
|
-
const want = cols.reduce((a, w, c) => a + Math.max(0, w - mins[c]), 0);
|
|
348
|
-
for (let c = 0; c < ncols; c++) {
|
|
349
|
-
cols[c] =
|
|
350
|
-
slack <= 0 || want <= 0
|
|
351
|
-
? mins[c]
|
|
352
|
-
: mins[c] + (Math.max(0, cols[c] - mins[c]) / want) * slack;
|
|
353
|
-
// Never below min-content, whatever the arithmetic above produced.
|
|
354
|
-
inner[c] = Math.max(content[c], cols[c] - hpad * 2);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
const tableW = cols.reduce((a, b) => a + b, 0);
|
|
358
|
-
|
|
359
|
-
const topY = y;
|
|
360
|
-
const rowLines = [y];
|
|
361
|
-
for (let r = 0; r < cellSpans.length; r++) {
|
|
362
|
-
const layouts = cellSpans[r].map((spans, c) =>
|
|
363
|
-
new TextLayout(fonts, spans.length ? spans : '', styles.base, {
|
|
364
|
-
maxWidth: inner[c],
|
|
365
|
-
align: block.align[c] ?? 'left',
|
|
366
|
-
lineHeight: t.lineHeight
|
|
367
|
-
})
|
|
368
|
-
);
|
|
369
|
-
const rowH = Math.max(...layouts.map((l) => l.height), t.size * t.lineHeight) + vpad * 2;
|
|
370
|
-
if (r === 0) {
|
|
371
|
-
this._items.push({ kind: 'rect', x, y, width: tableW, height: rowH, color: t.tableHeaderBg });
|
|
372
|
-
}
|
|
373
|
-
let cx = x;
|
|
374
|
-
for (let c = 0; c < ncols; c++) {
|
|
375
|
-
this._items.push({ kind: 'text', x: cx + hpad, y: y + vpad, layout: layouts[c] });
|
|
376
|
-
cx += cols[c];
|
|
377
|
-
}
|
|
378
|
-
y += rowH;
|
|
379
|
-
rowLines.push(y);
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// grid: horizontal rule per row boundary, vertical per column boundary
|
|
383
|
-
for (const ly of rowLines) {
|
|
384
|
-
this._items.push({ kind: 'rect', x, y: ly, width: tableW, height: 1, color: t.tableBorder });
|
|
385
|
-
}
|
|
386
|
-
let cx = x;
|
|
387
|
-
for (let c = 0; c <= ncols; c++) {
|
|
388
|
-
this._items.push({ kind: 'rect', x: cx, y: topY, width: 1, height: y - topY + 1, color: t.tableBorder });
|
|
389
|
-
if (c < ncols) cx += cols[c];
|
|
390
|
-
}
|
|
391
|
-
return y + 1;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/** inline AST -> styled spans for TextLayout */
|
|
395
|
-
_spans(children, base, state = {}, out = []) {
|
|
396
|
-
const t = this.theme;
|
|
397
|
-
for (const node of children) {
|
|
398
|
-
switch (node.type) {
|
|
399
|
-
case 'text':
|
|
400
|
-
out.push({
|
|
401
|
-
text: node.text,
|
|
402
|
-
family: state.code ? t.monoFamily : base.family,
|
|
403
|
-
size: state.code ? Math.round(base.size * 0.9) : base.size,
|
|
404
|
-
weight: state.bold ? 700 : base.weight,
|
|
405
|
-
style: state.italic ? 'italic' : base.style,
|
|
406
|
-
color: state.code ? t.codeColor : state.link ? t.linkColor : base.color,
|
|
407
|
-
_deco: state.code ? { bg: t.codeBg } : state.link ? { underline: t.linkColor } : null,
|
|
408
|
-
_href: state.link ? state.href : null
|
|
409
|
-
});
|
|
410
|
-
break;
|
|
411
|
-
case 'code':
|
|
412
|
-
this._spans([{ type: 'text', text: node.text }], base, { ...state, code: true }, out);
|
|
413
|
-
break;
|
|
414
|
-
case 'strong':
|
|
415
|
-
this._spans(node.children, base, { ...state, bold: true }, out);
|
|
416
|
-
break;
|
|
417
|
-
case 'em':
|
|
418
|
-
this._spans(node.children, base, { ...state, italic: true }, out);
|
|
419
|
-
break;
|
|
420
|
-
case 'link':
|
|
421
|
-
this._spans(node.children, base, { ...state, link: true, href: node.href }, out);
|
|
422
|
-
break;
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
return out;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
/** draw the last layout()'s items onto a 2d context */
|
|
429
|
-
draw(ctx, ox = 0, oy = 0) {
|
|
430
|
-
this._links = [];
|
|
431
|
-
for (const item of this._items) {
|
|
432
|
-
if (item.kind === 'rect') {
|
|
433
|
-
ctx.fillStyle = item.color;
|
|
434
|
-
ctx.fillRect(Math.round(ox + item.x), Math.round(oy + item.y), Math.ceil(item.width), Math.ceil(item.height));
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
for (const item of this._items) {
|
|
438
|
-
if (item.kind === 'tex') {
|
|
439
|
-
item.box.draw(ctx, ox + item.x, oy + item.y);
|
|
440
|
-
continue;
|
|
441
|
-
}
|
|
442
|
-
if (item.kind !== 'text') continue;
|
|
443
|
-
const x = ox + item.x;
|
|
444
|
-
const y = oy + item.y;
|
|
445
|
-
// span decorations (inline-code background, link underline) first
|
|
446
|
-
for (const line of item.layout.lines) {
|
|
447
|
-
for (const r of line.runs) {
|
|
448
|
-
if (r.span._href) {
|
|
449
|
-
// clickable region in the device coordinates of this draw
|
|
450
|
-
const m = r.run.font.metrics(r.run.size);
|
|
451
|
-
this._links.push({
|
|
452
|
-
x: x + line.x + r.x,
|
|
453
|
-
y: y + line.baseline - m.ascent,
|
|
454
|
-
w: r.width,
|
|
455
|
-
h: m.ascent + m.descent,
|
|
456
|
-
href: r.span._href
|
|
457
|
-
});
|
|
458
|
-
}
|
|
459
|
-
const deco = r.span._deco;
|
|
460
|
-
if (!deco) continue;
|
|
461
|
-
if (deco.bg) {
|
|
462
|
-
const m = r.run.font.metrics(r.run.size);
|
|
463
|
-
ctx.fillStyle = deco.bg;
|
|
464
|
-
ctx.fillRect(
|
|
465
|
-
Math.round(x + line.x + r.x - 2),
|
|
466
|
-
Math.round(y + line.baseline - m.ascent),
|
|
467
|
-
Math.ceil(r.width + 4),
|
|
468
|
-
Math.ceil(m.ascent + m.descent)
|
|
469
|
-
);
|
|
470
|
-
}
|
|
471
|
-
if (deco.underline) {
|
|
472
|
-
ctx.fillStyle = deco.underline;
|
|
473
|
-
ctx.fillRect(Math.round(x + line.x + r.x), Math.round(y + line.baseline + 2), Math.ceil(r.width), 1);
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
item.layout.draw(ctx, x, y);
|
|
478
|
-
}
|
|
479
|
-
return this;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
/** full window render: clear background, (re)layout on size change, draw */
|
|
483
|
-
render() {
|
|
484
|
-
if (!this.window) throw new Error('MarkdownView.render() needs a window');
|
|
485
|
-
const ctx = this._ctx;
|
|
486
|
-
if (this._layoutWidth !== this.window.width) this.layout(this.window.width);
|
|
487
|
-
ctx.fillStyle = this.theme.background;
|
|
488
|
-
ctx.fillRect(0, 0, this.window.width, this.window.height);
|
|
489
|
-
this.draw(ctx, 0, this.theme.padding);
|
|
490
|
-
return this;
|
|
491
|
-
}
|
|
492
|
-
}
|