rapier-markdown-kit 1.1.59 → 1.1.63

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.
@@ -1,5 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  import {readLedger} from './format.mjs';
3
+ import {sha256} from './hash.mjs';
3
4
  import {authorship as projectAuthorship, readAuthorship} from './authorship.mjs';
4
5
  import {canonicalJSON, fault} from './data.mjs';
5
6
  export function encodeCarried(text) {
@@ -67,3 +68,53 @@ export function readDocument(source) {
67
68
  const text = source.slice(0, at);
68
69
  return {text, ...validateParts(text, parts)};
69
70
  }
71
+
72
+ // One portable baseline. A digest is integrity evidence, not authentication of the named proposer.
73
+ export function readBase(value) {
74
+ if (!value || typeof value !== 'object' || typeof value.text !== 'string' ||
75
+ typeof value.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.sha256) || sha256(value.text) !== value.sha256)
76
+ throw fault('proposal base SHA-256 differs from its text');
77
+ if (typeof value.revision !== 'string' || !value.revision || value.revision.length > 256 || /[\u0000-\u001f\u007f]/.test(value.revision))
78
+ throw fault('invalid proposal base revision');
79
+ if (typeof value.name !== 'string' || !value.name || value.name.length > 255 || /[\\/\u0000-\u001f\u007f]/.test(value.name))
80
+ throw fault('invalid proposal base name');
81
+ if (typeof value.by !== 'string' || !value.by.trim() || value.by.length > 96 || /[\u0000-\u001f\u007f]/.test(value.by))
82
+ throw fault('a proposal needs its proposer name');
83
+ if (typeof value.at !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value.at) || !Number.isFinite(Date.parse(value.at)))
84
+ throw fault('the time of a proposal is an ISO 8601 UTC timestamp');
85
+ return {text: value.text, sha256: value.sha256, revision: value.revision, name: value.name, by: value.by, at: value.at};
86
+ }
87
+ const escapeAttribute = value => value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
88
+ const readAttribute = value => value.replace(/&(quot|lt|gt|amp);/g, (_, name) => ({quot: '"', lt: '<', gt: '>', amp: '&'})[name]);
89
+ export function writeBase(text, value) {
90
+ if (value == null) return '';
91
+ const base = readBase(value);
92
+ if (base.text === text) return '';
93
+ return '<script type="text/markdown" id="rapier-base"' + ['name', 'sha256', 'revision', 'by', 'at']
94
+ .map(key => ' data-' + key + '="' + escapeAttribute(base[key]) + '"').join('') + '>' + encodeCarried(base.text) + '</script>';
95
+ }
96
+ export function readBaseElements(elements) {
97
+ const rows = [...elements].filter(element => element.id === 'rapier-base');
98
+ if (!rows.length) return null;
99
+ const row = rows[0];
100
+ if (rows.length !== 1 || row.type !== 'text/markdown' || (row.tagName && row.tagName.toLowerCase() !== 'script'))
101
+ throw fault('duplicate or executable proposal base');
102
+ const get = name => row.getAttribute ? row.getAttribute('data-' + name) : row[name];
103
+ return readBase({text: decodeCarried(row.textContent), ...Object.fromEntries(['name', 'sha256', 'revision', 'by', 'at'].map(key => [key, get(key)]))});
104
+ }
105
+ export function takeBase(html) {
106
+ const elements = [];
107
+ const stripped = html.replace(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi, (whole, attrs, content) => {
108
+ const pairs = [...attrs.matchAll(/(?:^|\s)([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g)].map(m => [m[1].toLowerCase(), readAttribute(m[2] ?? m[3] ?? m[4])]);
109
+ if (!pairs.some(([key, value]) => key === 'id' && value === 'rapier-base')) return whole;
110
+ const seen = new Set(), values = {};
111
+ for (const [key, value] of pairs) {
112
+ if (seen.has(key)) throw fault('ambiguous proposal base');
113
+ seen.add(key); values[key] = value;
114
+ }
115
+ elements.push({id: values.id, type: values.type, textContent: content,
116
+ ...Object.fromEntries(['name', 'sha256', 'revision', 'by', 'at'].map(key => [key, values['data-' + key]]))});
117
+ return '';
118
+ });
119
+ return {html: stripped, base: readBaseElements(elements)};
120
+ }
@@ -406,7 +406,8 @@ async function _rapierSharedSourceCarrier(context, root) {
406
406
  (forms.definitions.length ? ' data-image-definitions="' + forms.definitions.map(encodeURIComponent).join(' ') + '"' : '') + '>\n' +
407
407
  forms.carried + '\n</script>\n' +
408
408
  // The optional edit ledger (W/4): carried only when the host binds its owner; a host without one writes none.
409
- (RapierLedgerCarried ? RapierLedgerCarried.writeParts(forms.resolved, _rapierLedgerParts(forms.resolved, context.ledger, context.carried)) : '');
409
+ (RapierLedgerCarried ? RapierLedgerCarried.writeParts(forms.resolved, _rapierLedgerParts(forms.resolved, context.ledger, context.carried)) +
410
+ RapierLedgerCarried.writeBase(forms.resolved, context.proposalBase) : '');
410
411
  }
411
412
 
412
413
  function _rapierSharedResolve(text, ids, images, definitions) {
@@ -438,7 +439,7 @@ async function _rapierReadSharedDocument(text, filename) {
438
439
  const template = document.createElement('template');
439
440
  template.innerHTML = html;
440
441
  const invalid = () => { throw new Error('The editable source in this web page is incomplete or changed'); };
441
- const carriers = template.content.querySelectorAll('script[type="' + _RAPIER_SHARED_SOURCE_TYPE + '"]');
442
+ const carriers = Array.from(template.content.querySelectorAll('script[type="' + _RAPIER_SHARED_SOURCE_TYPE + '"]')).filter(row => row.id !== 'rapier-base');
442
443
  if (carriers.length !== 1) return invalid();
443
444
  const carrier = carriers[0];
444
445
  const name = carrier.getAttribute('data-filename') || '', kind = carrier.getAttribute('data-kind'), sha256 = carrier.getAttribute('data-sha256') || '';
@@ -463,7 +464,8 @@ async function _rapierReadSharedDocument(text, filename) {
463
464
  const bom = source.charCodeAt(0) === 0xFEFF;
464
465
  if (RapierTextCodec.normalizeDocument(source) !== (bom ? source.slice(1) : source)) return invalid();
465
466
  const parts = RapierLedgerCarried ? RapierLedgerCarried.readParts(source, Array.from(template.content.querySelectorAll('#rapier-ledger, #rapier-authorship'))) : {};
466
- return {source, filename: name, kind, bom, ...parts};
467
+ const proposalBase = RapierLedgerCarried?.readBaseElements(Array.from(template.content.querySelectorAll('#rapier-base'))) || null;
468
+ return {source, filename: name, kind, bom, ...parts, proposalBase};
467
469
  }
468
470
 
469
471
  function _rapierSharedPageFallbackCss() {
@@ -577,6 +579,10 @@ function _rapierSharedPageLayout(root) {
577
579
  image.style.width = naturalWidth + 'px';
578
580
  image.style.marginLeft = image.style.marginRight = ((bounds.width - naturalWidth) / 2) + 'px';
579
581
  }
582
+ } else if (layout.lines != null) {
583
+ // Sized in lines: the float shrinks to the picture, whose height is the lines (spec/md-layout.mjs imageStyle).
584
+ image.style.width = 'auto';
585
+ image.style.height = globalThis.RapierMarkdownLayout.linesHeightCss(layout.lines);
580
586
  } else {
581
587
  if (layout.width != null) paragraph.style.width = layout.width + '%';
582
588
  else if (image.getAttribute('width')) paragraph.style.width = image.getAttribute('width') + 'px';
@@ -697,7 +703,7 @@ async function _rapierRequireOfflinePageImages(root) {
697
703
  }
698
704
 
699
705
  async function _rapierBuildSharedPage(captured) {
700
- const context = await _rapierPrepareInterchangeContext({kind: 'share'}, captured);
706
+ const context = await _rapierPrepareInterchangeContext({kind: 'share'}, captured?.proposalText == null ? captured : {...captured, canonical: captured.proposalText});
701
707
  // One owner for every HTML file Rapier writes, including offline-image admission and CSP.
702
708
  // Share adds only the nearest-side no-script float and the long-code treatment.
703
709
  const artifact = await _rapierBuildArtifact({
@@ -1306,7 +1312,9 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
1306
1312
 
1307
1313
  let ownerLed = false;
1308
1314
  for (const {element: source, image, layout, width: imageWidth} of anchors.get(element) || []) {
1309
- const unrotated = geometry.imageBox(width, image.naturalWidth, image.naturalHeight, layout, imageWidth);
1315
+ // A picture sized in lines (`lines`) reads its anchor's line and stands on the first line's cap height, as the live editor.
1316
+ const lines = layout.lines != null ? geometry.lineMetrics(element) : null;
1317
+ const unrotated = geometry.imageBox(width, image.naturalWidth, image.naturalHeight, layout, imageWidth, lines);
1310
1318
  if (!unrotated) { restore(); return; }
1311
1319
  // rasterReserved (layout/model.mjs).
1312
1320
  const rasterRad = (layout.rotate || 0) * Math.PI / 180;
@@ -1317,7 +1325,7 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
1317
1325
  const em = parseFloat(computed.fontSize) || 16;
1318
1326
  const topInset = (parseFloat(computed.paddingTop) || 0) + (parseFloat(computed.borderTopWidth) || 0);
1319
1327
  const height = Math.max(0, bounds.height - topInset - (parseFloat(computed.paddingBottom) || 0) - (parseFloat(computed.borderBottomWidth) || 0));
1320
- let y = bounds.top - origin + topInset + Math.min(layout.y || 0, height / em) * em;
1328
+ let y = bounds.top - origin + topInset + geometry.linesTop(layout, lines) + Math.min(layout.y || 0, height / em) * em;
1321
1329
  if (!outOfFlow(layout)) {
1322
1330
  for (const obstacle of [...obstacles].sort((left, right) => left.y - right.y)) {
1323
1331
  if (obstacle.x < left + rectangle.width + 10 && obstacle.x + obstacle.width > left - 10 &&
@@ -1325,14 +1333,14 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
1325
1333
  y = obstacle.y + obstacle.height + 10;
1326
1334
  }
1327
1335
  // R87k: a picture pushed down by an earlier one takes its owner with it. A deliberate `y` stays; only the collision push moves the paragraph.
1328
- const ownerTop = bounds.top - origin + topInset + Math.min(layout.y || 0, height / em) * em;
1336
+ const ownerTop = bounds.top - origin + topInset + geometry.linesTop(layout, lines) + Math.min(layout.y || 0, height / em) * em;
1329
1337
  // Only the first picture of an owner takes it down.
1330
1338
  if (!ownerLed && y > ownerTop + 0.5) {
1331
1339
  clearTo(element, (bounds.top - origin) + (y - ownerTop), origin, source);
1332
1340
  bounds = box(element);
1333
1341
  }
1334
1342
  ownerLed = true;
1335
- obstacles.push(...geometry.pictureSlices(profile(image, layout), left, y, rectangle.width, rectangle.height));
1343
+ obstacles.push(...geometry.linesSlices(geometry.pictureSlices(profile(image, layout), left, y, rectangle.width, rectangle.height), layout, lines, y, rectangle.height));
1336
1344
  }
1337
1345
  placed.push({source, image, rectangle, left, y, wrap: layout.wrap,
1338
1346
  visualLeft: left + visualDeltaX, visualTop: y + visualDeltaY, visualWidth: fit.fit.width, visualHeight: fit.fit.height,
@@ -82,10 +82,54 @@ export function mapFragment(run, fragment) {
82
82
  return {text: fragment.text, offsets, start: run.offsets[from], end: run.offsets[to]};
83
83
  }
84
84
 
85
- export function imageBox(columnWidth, naturalWidth, naturalHeight, layout = {}, defaultWidth = naturalWidth) {
85
+ // The line a picture is measured in (`lines`, spec/md-layout.mjs): the anchor's line box, its type's cap height, and where that
86
+ // cap height stands in the first line box (the half-leading model: the baseline sits half the leading below the box's top plus
87
+ // the ascent). Read from the font as the page lays it out, so a text size or a line spacing moves the picture with the words.
88
+ const fontMetrics = new Map(), measurers = new WeakMap();
89
+ export function lineMetrics(element) {
90
+ const view = element?.ownerDocument?.defaultView;
91
+ if (!view) return null;
92
+ const computed = view.getComputedStyle(element), size = parseFloat(computed.fontSize) || 16;
93
+ const font = computed.fontStyle + ' ' + computed.fontWeight + ' ' + size + 'px ' + computed.fontFamily;
94
+ let metrics = fontMetrics.get(font);
95
+ if (!metrics) {
96
+ let context = measurers.get(element.ownerDocument);
97
+ if (!context) measurers.set(element.ownerDocument, context = element.ownerDocument.createElement('canvas').getContext('2d'));
98
+ if (!context) return null;
99
+ context.font = font;
100
+ const capital = context.measureText('H');
101
+ metrics = {ascent: capital.fontBoundingBoxAscent, descent: capital.fontBoundingBoxDescent, cap: capital.actualBoundingBoxAscent};
102
+ if (![metrics.ascent, metrics.descent, metrics.cap].every(finite) || !(metrics.cap > 0)) return null;
103
+ // A face still loading measures as its fallback: keep the answer only once the face is in.
104
+ if (element.ownerDocument.fonts?.check?.(font) !== false && element.ownerDocument.fonts?.status !== 'loading') fontMetrics.set(font, metrics);
105
+ }
106
+ const line = parseFloat(computed.lineHeight) || metrics.ascent + metrics.descent;
107
+ return {line, cap: metrics.cap, capTop: (line - metrics.ascent - metrics.descent) / 2 + metrics.ascent - metrics.cap};
108
+ }
109
+
110
+ // `lines` tall: from the first line's cap height to the Nth line's baseline, N - 1 lines and one cap.
111
+ export function linesHeight(lines, metrics) {
112
+ const height = Number.isInteger(lines) && lines > 0 && metrics && finite(metrics.line) && finite(metrics.cap)
113
+ ? (lines - 1) * metrics.line + metrics.cap : NaN;
114
+ return height > 0 ? height : null;
115
+ }
116
+
117
+ // A picture sized in lines keeps its standoff off the line after its last: below its foot (the Nth baseline) the gap stops
118
+ // at that line's bottom, so exactly N lines stand beside it.
119
+ export function linesSlices(slices, layout, metrics, y, height) {
120
+ if (layout?.lines == null || !metrics || !(slices?.length)) return slices;
121
+ const floor = y + height + Math.max(0, metrics.line - metrics.capTop - metrics.cap) - .5;
122
+ return slices.filter(slice => slice.y < floor).map(slice => slice.y + slice.height > floor ? {...slice, height: floor - slice.y} : slice);
123
+ }
124
+
125
+ // Where a picture's top stands in its anchor before `y`: a picture sized in lines stands on the first line's cap height.
126
+ export const linesTop = (layout, metrics) => layout?.lines != null && finite(metrics?.capTop) ? metrics.capTop : 0;
127
+
128
+ export function imageBox(columnWidth, naturalWidth, naturalHeight, layout = {}, defaultWidth = naturalWidth, metrics = null) {
86
129
  if (![columnWidth, naturalWidth, naturalHeight, defaultWidth].every(value => finite(value) && value > 0) ||
87
130
  !validLayout(layout) || layout.align === 'justify') return null;
88
- const requestedWidth = layout.width == null ? defaultWidth : columnWidth * layout.width / 100;
131
+ const tall = layout.lines == null ? null : linesHeight(layout.lines, metrics);
132
+ const requestedWidth = tall ? tall * naturalWidth / naturalHeight : layout.width == null ? defaultWidth : columnWidth * layout.width / 100;
89
133
  if (!finite(requestedWidth) || requestedWidth <= 0) return null;
90
134
  const width = Math.min(columnWidth, requestedWidth), height = width * naturalHeight / naturalWidth;
91
135
  const center = layout.x == null ? width / 2 : columnWidth * layout.x / 100;
@@ -2,13 +2,16 @@
2
2
 
3
3
  // `rotate`: degrees in (-180, 180], one decimal, omitted when 0; never written for a Rapier drawing (its turn lives in its SVG).
4
4
  // `opacity`: a picture's fade, a whole percent from 5 to 100, omitted at 100; the picture's bytes never change.
5
+ // `lines`: a picture's height in lines of the text beside it, set as a drop cap is (CSS `initial-letter`): its top on the first
6
+ // line's cap height, its foot on the Nth line's baseline, its width from its own aspect. A whole number 1 to 12; never with `width`.
5
7
  // `first`/`indent`: a paragraph's first-line indent and its whole-block indent, in levels of one step (2em), a whole number from 1 to 4, omitted when 0; text only.
6
- export const fields = new Set(['align', 'width', 'wrap', 'x', 'y', 'rotate', 'opacity', 'first', 'indent']);
8
+ export const fields = new Set(['align', 'width', 'lines', 'wrap', 'x', 'y', 'rotate', 'opacity', 'first', 'indent']);
7
9
  export const alignments = new Set(['left', 'center', 'right', 'justify']);
8
10
  // `behind`/`front`: out of flow; the paragraph lays out as though the picture were absent.
9
11
  export const wraps = new Set(['around', 'box', 'behind', 'front']);
10
12
  const has = (value, key) => Object.hasOwn(value, key);
11
13
  const percent = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100;
14
+ const lineCount = value => Number.isInteger(value) && value >= 1 && value <= 12;
12
15
  const level = value => Number.isInteger(value) && value >= 1 && value <= 4;
13
16
  const oneDecimalDegrees = value => typeof value === 'number' && Number.isFinite(value) &&
14
17
  value > -180 && value <= 180 && Math.abs(value * 10 - Math.round(value * 10)) < 1e-9;
@@ -19,6 +22,7 @@ export function validLayout(value) {
19
22
  Object.keys(value).some(key => !fields.has(key))) return false;
20
23
  return (!has(value, 'align') || alignments.has(value.align)) &&
21
24
  (!has(value, 'width') || percent(value.width) && value.width > 0) &&
25
+ (!has(value, 'lines') || lineCount(value.lines) && !has(value, 'width')) &&
22
26
  (!has(value, 'wrap') || wraps.has(value.wrap)) &&
23
27
  (!has(value, 'x') || percent(value.x) && (has(value, 'wrap') || has(value, 'width'))) &&
24
28
  (!has(value, 'y') || typeof value.y === 'number' && Number.isFinite(value.y) && value.y >= -50 && has(value, 'wrap')) &&
@@ -59,6 +63,9 @@ export function parseLayout(comment) {
59
63
  } else if (key === 'opacity') {
60
64
  if (!/^[1-9]\d{0,2}%$/.test(raw)) return null;
61
65
  value.opacity = Number(raw.slice(0, -1));
66
+ } else if (key === 'lines') {
67
+ if (!/^[1-9]\d?$/.test(raw)) return null;
68
+ value.lines = Number(raw);
62
69
  } else if (key === 'first' || key === 'indent') {
63
70
  if (!/^[1-4]$/.test(raw)) return null;
64
71
  value[key] = Number(raw);
@@ -75,7 +82,8 @@ export function decodeLayoutAttribute(value) {
75
82
 
76
83
  export const parseLayoutAttribute = value => parseLayout(decodeLayoutAttribute(value));
77
84
 
78
- // The picture's own CSS: its normal-flow width and x, and its fade.
85
+ // The picture's own CSS: its normal-flow width and x, its height in lines, and its fade. `1cap` is the cap height of the
86
+ // picture's own text; a renderer that sets the picture beside its anchor measures that paragraph's line and cap instead.
79
87
  export function imageStyle(value) {
80
88
  if (!validLayout(value)) return '';
81
89
  const parts = [];
@@ -85,10 +93,13 @@ export function imageStyle(value) {
85
93
  decimal(Number(Math.max(0, Math.min(100 - width, value.x - width / 2)).toFixed(6))) + '%;margin-right:0';
86
94
  parts.push('width:' + decimal(width) + '%;height:auto' + left);
87
95
  }
96
+ if (value.lines != null) parts.push('width:auto;max-width:100%;height:' + linesHeightCss(value.lines));
88
97
  if (value.opacity != null && value.opacity < 100) parts.push('opacity:' + decimal(value.opacity / 100));
89
98
  return parts.join(';');
90
99
  }
91
100
 
101
+ export const linesHeightCss = lines => lines === 1 ? '1cap' : 'calc(' + (lines - 1) + ' * var(--md-line, 1.6em) + 1cap)';
102
+
92
103
  // Controls remain real DOM nodes while their surrounding text flows. A checkbox, link or
93
104
  // disclosure is not a reason to exclude its entire block from picture placement.
94
105
  export function wrapTextBlock(element) {
@@ -11,11 +11,12 @@ const inkReader = () => {
11
11
 
12
12
  // Ink (docs/markdown-standard.md, "Ink"; the design docs/briefs/ink.md): a pen stroke that stays with the words it
13
13
  // marks, a paired comment with the colour span's shape. The opener holds the stroke:
14
- // <!--ink KIND [NUMBER] [COLOUR] [box=W,H] [at=X,Y] [PATH]-->words<!--/ink-->
14
+ // <!--ink KIND [NUMBER] [COLOUR] [w=N] [box=W,H] [at=X,Y] [PATH]-->words<!--/ink-->
15
15
  // KIND is the stroke's kind, decided once when it was lifted; COLOUR is exactly the colour span's value, red when
16
16
  // absent; box is the frame's size when the stroke was drawn and at the frame's offset from the words' box, both in
17
- // hundredths of an em; PATH is the stroke, its first point absolute in the frame and the rest as moves, hundredths of an
18
- // em. One spelling: the fields in this order, one space between, integers only. A comment that does not read this way
17
+ // hundredths of an em; w is the pen's width as Draw counts a nib (2 to 24; 9 is the default and is never written): the stroke is drawn 0.11 em
18
+ // wide at 9 and in proportion to w otherwise; PATH is the stroke, its first point absolute in the frame and the rest as
19
+ // moves, hundredths of an em. One spelling: the fields in this order, one space between, integers only. A comment that does not read this way
19
20
  // is ordinary comment text and marks nothing. Ink pairs do not nest in each other. Arrow/end alone take a pairing
20
21
  // number, an end takes no other data; under, strike and arrow may omit a path for a line derived from their words.
21
22
  const INK_KINDS = Object.freeze(['under', 'strike', 'ring', 'bracket', 'free', 'arrow', 'end']);
@@ -23,7 +24,7 @@ const inkReader = () => {
23
24
  const INK_PATH_MAX = 160;
24
25
  const INK_INT_MAX = 999999;
25
26
  const INK_INT = '-?\\d{1,6}';
26
- const INK_OPEN_BODY = '(' + INK_KINDS.join('|') + ')(?: ([1-9]\\d{0,5}))?(?: (' + COLOR_VALUE + '))?(?: box=(\\d{1,6}),(\\d{1,6}))?(?: at=(' + INK_INT + '),(' + INK_INT + '))?'
27
+ const INK_OPEN_BODY = '(' + INK_KINDS.join('|') + ')(?: ([1-9]\\d{0,5}))?(?: (' + COLOR_VALUE + '))?(?: w=([1-9]\\d?))?(?: box=(\\d{1,6}),(\\d{1,6}))?(?: at=(' + INK_INT + '),(' + INK_INT + '))?'
27
28
  + '(?: (' + INK_INT + ',' + INK_INT + '(?: ' + INK_INT + ',' + INK_INT + ')*))?';
28
29
  const INK_OPEN_PATTERN = '<!--ink ' + INK_OPEN_BODY + '-->';
29
30
 
@@ -32,9 +33,11 @@ const inkReader = () => {
32
33
  // INK_OPEN_PATTERN and this function; isInkClose reads INK_CLOSE.
33
34
  function readInkMatch(match) {
34
35
  const linked = match[1] === 'arrow' || match[1] === 'end';
35
- if (linked !== (match[2] != null) || (!linked && match[1] !== 'under' && match[1] !== 'strike' && match[8] == null)) return null;
36
- if (match[1] === 'end' && match.slice(3, 9).some(value => value != null)) return null;
37
- const moves = match[8] == null ? [] : match[8].split(' ').map(pair => pair.split(',').map(Number));
36
+ if (linked !== (match[2] != null) || (!linked && match[1] !== 'under' && match[1] !== 'strike' && match[9] == null)) return null;
37
+ if (match[1] === 'end' && match.slice(3, 10).some(value => value != null)) return null;
38
+ const width = match[4] == null ? null : Number(match[4]);
39
+ if (width != null && (width < 2 || width > 24 || width === 9)) return null;
40
+ const moves = match[9] == null ? [] : match[9].split(' ').map(pair => pair.split(',').map(Number));
38
41
  if (moves.length > INK_PATH_MAX) return null;
39
42
  const path = moves.length ? [moves[0]] : [];
40
43
  for (let i = 1; i < moves.length; i++) {
@@ -47,8 +50,9 @@ const inkReader = () => {
47
50
  kind: match[1],
48
51
  ...(linked ? { id: Number(match[2]) } : {}),
49
52
  hex: colour == null ? null : (colour.charCodeAt(0) === 0x23 ? colour : TEXT_COLOR_NAMES[colour]),
50
- box: match[4] == null ? null : [Number(match[4]), Number(match[5])],
51
- at: match[6] == null ? null : [Number(match[6]), Number(match[7])],
53
+ ...(width == null ? {} : { width }),
54
+ box: match[5] == null ? null : [Number(match[5]), Number(match[6])],
55
+ at: match[7] == null ? null : [Number(match[7]), Number(match[8])],
52
56
  path,
53
57
  });
54
58
  }
@@ -144,7 +148,7 @@ const inkPair = (value, name, nonNegative = false) => {
144
148
  return pair;
145
149
  };
146
150
 
147
- // {kind, id?, hex?, box?, at?, path?} to the opener; the path's points are absolute in the frame and become moves after the
151
+ // {kind, id?, hex?, width?, box?, at?, path?} to the opener; the path's points are absolute in the frame and become moves after the
148
152
  // first. Refuses with a TypeError rather than writing a spelling the reader would not read.
149
153
  export function formatInkOpen(mark) {
150
154
  if (!mark || typeof mark !== 'object') throw new TypeError('ink: a mark is an object');
@@ -153,7 +157,7 @@ export function formatInkOpen(mark) {
153
157
  if (linked ? !Number.isInteger(mark.id) || mark.id <= 0 || mark.id > INK_INT_MAX : mark.id != null) throw new TypeError('ink: only an arrow or its end has a positive pairing number');
154
158
  const id = linked ? ' ' + mark.id : '';
155
159
  if (mark.kind === 'end') {
156
- if (mark.hex != null || mark.box != null || mark.at != null || (mark.path != null && (!Array.isArray(mark.path) || mark.path.length))) throw new TypeError('ink: an arrow end holds only its pairing number');
160
+ if (mark.hex != null || mark.width != null || mark.box != null || mark.at != null || (mark.path != null && (!Array.isArray(mark.path) || mark.path.length))) throw new TypeError('ink: an arrow end holds only its pairing number');
157
161
  return '<!--ink end' + id + '-->';
158
162
  }
159
163
  let colour = '';
@@ -162,6 +166,8 @@ export function formatInkOpen(mark) {
162
166
  if (!new RegExp('^' + COLOR_VALUE + '$').test(value)) throw new TypeError('ink: the colour is the colour span\'s value');
163
167
  colour = ' ' + value;
164
168
  }
169
+ if (mark.width != null && (!Number.isInteger(mark.width) || mark.width < 2 || mark.width > 24)) throw new TypeError('ink: the width is an integer from 2 to 24, and 9 is spelled by leaving it out');
170
+ const width = mark.width == null || mark.width === 9 ? '' : ' w=' + mark.width;
165
171
  const box = mark.box == null ? '' : ' box=' + inkPair(mark.box, 'box', true).join(',');
166
172
  const at = mark.at == null ? '' : ' at=' + inkPair(mark.at, 'at').join(',');
167
173
  const optionalPath = linked || mark.kind === 'under' || mark.kind === 'strike';
@@ -170,7 +176,7 @@ export function formatInkOpen(mark) {
170
176
  const points = path.map(point => inkPair(point, 'path'));
171
177
  const moves = points.length ? [points[0].join(',')] : [];
172
178
  for (let i = 1; i < points.length; i++) moves.push([inkInt(points[i][0] - points[i - 1][0], 'path move'), inkInt(points[i][1] - points[i - 1][1], 'path move')].join(','));
173
- return '<!--ink ' + mark.kind + id + colour + box + at + (moves.length ? ' ' + moves.join(' ') : '') + '-->';
179
+ return '<!--ink ' + mark.kind + id + colour + width + box + at + (moves.length ? ' ' + moves.join(' ') : '') + '-->';
174
180
  }
175
181
 
176
182
  export function formatInkRun(mark, content) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rapier-markdown-kit",
3
- "version": "1.1.59",
3
+ "version": "1.1.63",
4
4
  "description": "Read, render, write and style Self-contained Markdown, and carry its optional edit ledger: the shared HTML writer, semantic renderer, stylesheet, layout, marks, pictures, Will/1, replay, authorship and offline merge. MIT, no npm dependencies.",
5
5
  "keywords": [
6
6
  "markdown",