rapier-markdown-kit 1.1.62 → 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.
- package/dist/kit/render.mjs +10 -4
- package/dist/layout/model.mjs +46 -2
- package/dist/spec/md-layout.mjs +13 -2
- package/package.json +1 -1
package/dist/kit/render.mjs
CHANGED
|
@@ -579,6 +579,10 @@ function _rapierSharedPageLayout(root) {
|
|
|
579
579
|
image.style.width = naturalWidth + 'px';
|
|
580
580
|
image.style.marginLeft = image.style.marginRight = ((bounds.width - naturalWidth) / 2) + 'px';
|
|
581
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);
|
|
582
586
|
} else {
|
|
583
587
|
if (layout.width != null) paragraph.style.width = layout.width + '%';
|
|
584
588
|
else if (image.getAttribute('width')) paragraph.style.width = image.getAttribute('width') + 'px';
|
|
@@ -1308,7 +1312,9 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
|
|
|
1308
1312
|
|
|
1309
1313
|
let ownerLed = false;
|
|
1310
1314
|
for (const {element: source, image, layout, width: imageWidth} of anchors.get(element) || []) {
|
|
1311
|
-
|
|
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);
|
|
1312
1318
|
if (!unrotated) { restore(); return; }
|
|
1313
1319
|
// rasterReserved (layout/model.mjs).
|
|
1314
1320
|
const rasterRad = (layout.rotate || 0) * Math.PI / 180;
|
|
@@ -1319,7 +1325,7 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
|
|
|
1319
1325
|
const em = parseFloat(computed.fontSize) || 16;
|
|
1320
1326
|
const topInset = (parseFloat(computed.paddingTop) || 0) + (parseFloat(computed.borderTopWidth) || 0);
|
|
1321
1327
|
const height = Math.max(0, bounds.height - topInset - (parseFloat(computed.paddingBottom) || 0) - (parseFloat(computed.borderBottomWidth) || 0));
|
|
1322
|
-
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;
|
|
1323
1329
|
if (!outOfFlow(layout)) {
|
|
1324
1330
|
for (const obstacle of [...obstacles].sort((left, right) => left.y - right.y)) {
|
|
1325
1331
|
if (obstacle.x < left + rectangle.width + 10 && obstacle.x + obstacle.width > left - 10 &&
|
|
@@ -1327,14 +1333,14 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
|
|
|
1327
1333
|
y = obstacle.y + obstacle.height + 10;
|
|
1328
1334
|
}
|
|
1329
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.
|
|
1330
|
-
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;
|
|
1331
1337
|
// Only the first picture of an owner takes it down.
|
|
1332
1338
|
if (!ownerLed && y > ownerTop + 0.5) {
|
|
1333
1339
|
clearTo(element, (bounds.top - origin) + (y - ownerTop), origin, source);
|
|
1334
1340
|
bounds = box(element);
|
|
1335
1341
|
}
|
|
1336
1342
|
ownerLed = true;
|
|
1337
|
-
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));
|
|
1338
1344
|
}
|
|
1339
1345
|
placed.push({source, image, rectangle, left, y, wrap: layout.wrap,
|
|
1340
1346
|
visualLeft: left + visualDeltaX, visualTop: y + visualDeltaY, visualWidth: fit.fit.width, visualHeight: fit.fit.height,
|
package/dist/layout/model.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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;
|
package/dist/spec/md-layout.mjs
CHANGED
|
@@ -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) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rapier-markdown-kit",
|
|
3
|
-
"version": "1.1.
|
|
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",
|