rapier-markdown-kit 1.1.99 → 1.1.101
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/LICENSE +10 -2
- package/README.md +42 -8
- package/dist/kit/export-fonts.mjs +403 -0
- package/dist/kit/ledger/data.mjs +1 -1
- package/dist/kit/ledger/format.mjs +12 -5
- package/dist/kit/ledger/hash.mjs +16 -22
- package/dist/kit/ledger/history.mjs +59 -276
- package/dist/kit/ledger/inverse-source.mjs +316 -0
- package/dist/kit/ledger/journal-records.mjs +116 -12
- package/dist/kit/ledger/merge.mjs +7 -4
- package/dist/kit/ledger/transport.mjs +123 -19
- package/dist/kit/pdf-glyphs.mjs +159 -0
- package/dist/kit/pdf-vectors.mjs +204 -0
- package/dist/kit/print-capitals.mjs +85 -0
- package/dist/kit/rapier-ledger.mjs +2 -2
- package/dist/kit/render-entry.mjs +1 -0
- package/dist/kit/render-ink.mjs +132 -0
- package/dist/kit/render-markdown.mjs +71 -17
- package/dist/kit/render-print.mjs +208 -60
- package/dist/kit/render-sanitize.mjs +23 -7
- package/dist/kit/render-styles.mjs +8 -12
- package/dist/kit/render.mjs +365 -133
- package/dist/layout/model.mjs +26 -20
- package/dist/layout/typography.mjs +364 -0
- package/dist/packages/rapier-diff/LICENSE +21 -0
- package/dist/packages/rapier-diff/index.mjs +610 -0
- package/dist/spec/document-fonts.mjs +244 -0
- package/dist/spec/document-settings.mjs +494 -0
- package/dist/spec/frontmatter.mjs +30 -0
- package/dist/spec/html-blocks.mjs +143 -0
- package/dist/spec/md-assets.mjs +77 -5
- package/dist/spec/md-layout.mjs +7 -3
- package/dist/spec/md-marks.mjs +114 -8
- package/package.json +1 -1
- package/style.css +110 -52
- package/dist/agent/diff.mjs +0 -52
package/LICENSE
CHANGED
|
@@ -22,7 +22,9 @@ SOFTWARE.
|
|
|
22
22
|
|
|
23
23
|
Files under this licence carry `SPDX-License-Identifier: MIT` in their first lines: the standard's
|
|
24
24
|
grammar, marks and reference style (`spec/md-layout.mjs`, `spec/md-marks.mjs`, `spec/flowchart.mjs`, `spec/markdown-style.css`), the layout model and line planner
|
|
25
|
-
(`layout/model.mjs`, `layout/line-plan.mjs
|
|
25
|
+
(`layout/model.mjs`, `layout/line-plan.mjs`, `layout/typography.mjs`),
|
|
26
|
+
the portable HTML and document metadata helpers (`spec/html-blocks.mjs`, `spec/frontmatter.mjs`,
|
|
27
|
+
`spec/document-settings.mjs`, `spec/document-fonts.mjs`), the Will grammar (`agent/will.mjs`), the picture appendix reader and writer
|
|
26
28
|
(`spec/md-assets.mjs`), the two shared-page reference readers (`tools/read-shared-page.mjs`,
|
|
27
29
|
`tools/read-shared-page.py`), and the host helper `packages/rapier-embed/embed.mjs` with its
|
|
28
30
|
contract owner `packages/rapier-embed/contract.mjs`. The vendored Pretext (`agent/vendor/pretext`) and micromark carry their
|
|
@@ -32,12 +34,18 @@ record validator/replayer and text-only conflict merger were moved, not copied,
|
|
|
32
34
|
into that module; the Notes index merger and the rest of the agent/editor remain
|
|
33
35
|
AGPL-3.0-only. The wrapper's carrier-staging helper
|
|
34
36
|
(`skills/rapier-html/package-files.mjs`) is MIT. The kit's MIT-marked facades,
|
|
35
|
-
`kit/render-entry.mjs`, `kit/render*.mjs`, `kit/
|
|
37
|
+
`kit/render-entry.mjs`, `kit/render*.mjs`, `kit/export-fonts.mjs`, `kit/print-capitals.mjs`,
|
|
38
|
+
`kit/pdf-vectors.mjs`, `kit/pdf-glyphs.mjs`, `kit/stage.mjs` and MIT-marked conformance
|
|
36
39
|
modules are covered too. These names describe the Rapier-owned files carrying the
|
|
37
40
|
MIT notice, not a grant over the editor or third-party code. Other Rapier-owned
|
|
38
41
|
files retain their stated licence; the editor is AGPL-3.0-only (see `LICENSE`).
|
|
39
42
|
Third-party components retain their own copyright and licence notices.
|
|
40
43
|
|
|
44
|
+
The portable font helpers read document definitions and accept explicit host validation and
|
|
45
|
+
subsetting functions. The application binding (`editor/export-fonts.mjs`), captured-font
|
|
46
|
+
materializer (`kit/document-fonts.mjs`) and font codecs (`draw/font*.mjs`) remain AGPL-3.0-only
|
|
47
|
+
and are not part of the MIT package's import graph.
|
|
48
|
+
|
|
41
49
|
The picture appendix reader and writer used to be part of `images/assets.mjs`, a file that also
|
|
42
50
|
holds JPEG XL/raster header inspection and SVG sanitizing -- both of which reach the editor's own
|
|
43
51
|
AGPL-3.0-only decoders (`images/raster.mjs`, `draw/font.mjs`), so the whole file could not honestly
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ To show documents in your app without writing a renderer, frame the read-only re
|
|
|
17
17
|
offline file, run `npx rapier-html notes.md`.
|
|
18
18
|
|
|
19
19
|
In the editor, an agent edits live beside the person or while they are away. Rapier shows its presence and changes;
|
|
20
|
-
the person taps a change to see what was there before and undoes anything. `comparison
|
|
20
|
+
the person taps a change to see what was there before and undoes anything. `show {target:"comparison"}` lets the agent
|
|
21
21
|
deliberately show a diff. The kit supplies the portable source and history those tools use.
|
|
22
22
|
|
|
23
23
|
```sh
|
|
@@ -37,7 +37,7 @@ import {parseAssets} from 'rapier-markdown-kit/assets'; // the picture appendi
|
|
|
37
37
|
Rapier is a phone-first Markdown editor for notes, diagrams, drawing and watercolor painting. It runs offline
|
|
38
38
|
as one HTML file on Android, Web and Windows.
|
|
39
39
|
|
|
40
|
-
1. **Work together live.** Connect to `https://mcp.rapier.website/mcp`, then call `
|
|
40
|
+
1. **Work together live.** Connect to `https://mcp.rapier.website/mcp`, then call `open` to share one document with the person.
|
|
41
41
|
2. **Deliver an offline editor.** Run `npx rapier-html notes.md` to put the document and editor in one HTML file ([rapier-html](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-html)).
|
|
42
42
|
3. **Embed in your app.** Install [rapier-embed](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-embed); mount the editor or reader, with your app's agent over WebMCP in the editor.
|
|
43
43
|
4. **Keep portable Markdown.** Use [rapier-markdown-kit](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-markdown-kit) for pictures, editable SVG drawings and layout in one `.md` file. Respect [Will/1](https://github.com/jackskip22/will) `keep`, `append` and `edit` regions.
|
|
@@ -47,7 +47,8 @@ as one HTML file on Android, Web and Windows.
|
|
|
47
47
|
## The shared renderer
|
|
48
48
|
|
|
49
49
|
`rapier-markdown-kit/render` exports `createRenderer`, `createMarkdownRenderer`,
|
|
50
|
-
`createRenderStyles`, `createRenderSanitizer` and `createPrintRenderer
|
|
50
|
+
`createRenderStyles`, `createRenderSanitizer` and `createPrintRenderer`, plus the explicit font helpers
|
|
51
|
+
`exportFontCss`, `requireDocumentFonts`, `fontRequests`, `waitForFonts` and `waitForFontRequests`. These are the
|
|
51
52
|
functions used by the editor and exported pages. Each factory takes explicit host dependencies
|
|
52
53
|
in its first argument. `style.css` is the
|
|
53
54
|
MIT `spec/markdown-style.css`, including the numbered-list, checkbox, callout, table,
|
|
@@ -56,8 +57,8 @@ footnote and picture-layout treatment.
|
|
|
56
57
|
```js
|
|
57
58
|
import {createRenderer} from 'rapier-markdown-kit/render';
|
|
58
59
|
|
|
59
|
-
// host
|
|
60
|
-
//
|
|
60
|
+
// host binds the DOM, Markdown, image, style and font capabilities described below.
|
|
61
|
+
// Preparation returns a detached semantic root; source comes from this captured request.
|
|
61
62
|
const writer = createRenderer(host);
|
|
62
63
|
const {html, filename} = await writer.render(source, {filename: 'report.md'});
|
|
63
64
|
```
|
|
@@ -80,6 +81,39 @@ not part of this MIT package.
|
|
|
80
81
|
Serialization tests preserve source bytes through HTML export and recovery. Server integration
|
|
81
82
|
tests also check the exported HTTP response through a browser DOM.
|
|
82
83
|
|
|
84
|
+
### Fonts in exported HTML
|
|
85
|
+
|
|
86
|
+
Settings HTML and Share pages use the same offline font policy. The writer reads custom font
|
|
87
|
+
definitions from the captured Markdown and carries their exact bytes and face metadata. A used
|
|
88
|
+
custom family must have valid embedded data; otherwise export stops without changing the document.
|
|
89
|
+
Font preparation and binary validation are explicit host capabilities:
|
|
90
|
+
|
|
91
|
+
| Host port | Contract |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `materializeDocumentFonts(source, {parser})` | Optional at `render()` entry; returns the prepared Markdown string or a promise for it. Without this port, the caller supplies already portable, captured Markdown. Materialization must preserve explicit font authority and return source for this request. |
|
|
94
|
+
| `validateFont(bytes, {family, format})` | Validates a `Uint8Array` and returns exactly `true`, synchronously or asynchronously, only on admission. Required used custom fonts refuse with `export_font_unavailable` if the port is absent and `export_font_invalid` if it returns another value. Host errors and cancellation propagate. |
|
|
95
|
+
|
|
96
|
+
Browser hosts additionally check the exact captured container with an uninstalled `FontFace`.
|
|
97
|
+
Without `FontFace`, only the host validator's admission is established; it does not establish
|
|
98
|
+
browser container support. The HTML font helpers do not bundle the application's font importer,
|
|
99
|
+
container codecs or subsetter. Rapier binds its existing materializer and shared importer at `editor/render-host.js` through
|
|
100
|
+
`editor/export-fonts.mjs`; those implementations retain their application licence.
|
|
101
|
+
An embedded family named Geist or Geist Mono remains custom data and takes precedence over the
|
|
102
|
+
factory family. Custom fonts have no optional factory size limit.
|
|
103
|
+
|
|
104
|
+
Factory typography may add a genuine subset of Geist Sans for the page's actual text, using the
|
|
105
|
+
host's subsetter. `exportFontCss` accepts `subset` and `validateFont` ports; with `portableFactory:true`,
|
|
106
|
+
missing or unsuccessful validation omits this optional CSS. The complete set of used factory faces must fit both 8 KiB of font payload and
|
|
107
|
+
12 KiB of emitted font CSS, including base64. If subsetting is unavailable, unsafe, unchanged or too
|
|
108
|
+
large, the page uses the existing local/system fallback. HTML never carries factory Geist Mono;
|
|
109
|
+
code and native diagram labels retain their existing monospace fallbacks. No font is downloaded
|
|
110
|
+
when the saved file is opened, and optional factory fonts never enter the carried Markdown.
|
|
111
|
+
|
|
112
|
+
Publishing HTML remains a fragment: it carries only its custom font declarations and local
|
|
113
|
+
body, heading, code and inline choices, without a page wrapper or a factory bundle. The host must
|
|
114
|
+
permit inline styles and data fonts. Two fragments with different custom faces under the same
|
|
115
|
+
family name share the host's font namespace. Print retains its existing factory font policy.
|
|
116
|
+
|
|
83
117
|
## What it gives you
|
|
84
118
|
|
|
85
119
|
| Import | What it is |
|
|
@@ -291,8 +325,8 @@ tolerance.
|
|
|
291
325
|
## Licence
|
|
292
326
|
|
|
293
327
|
MIT, the full text in `LICENSE`; Rapier-owned modules and the stylesheet keep their MIT line. Pretext's licence, notice and
|
|
294
|
-
pinned source inventory are in `dist/agent/vendor/pretext/`. The history projection
|
|
295
|
-
|
|
328
|
+
pinned source inventory are in `dist/agent/vendor/pretext/`. The history projection uses the MIT diff core;
|
|
329
|
+
its full grant is in `dist/packages/rapier-diff/LICENSE`. The Rapier editor is AGPL-3.0-only and
|
|
296
330
|
none of it is in this package. The kit carries Rapier's release number, written from the one
|
|
297
331
|
value the editor's release reads.
|
|
298
332
|
|
|
@@ -342,7 +376,7 @@ transaction: {id, documentAuthority, baseRevision, revision, actor: {kind, id},
|
|
|
342
376
|
retained record. Every outgoing record carries its own splices, including Undo and
|
|
343
377
|
Redo. Offsets and ranges count UTF-16 code units; no boundary may cut a surrogate
|
|
344
378
|
pair. Splices execute in their recorded order. UTF-8 checksums preserve CRLF, tabs,
|
|
345
|
-
NUL and non-ASCII text. The canonical text limit is
|
|
379
|
+
NUL and non-ASCII text. The canonical text limit is 64 MiB; each record has at most
|
|
346
380
|
64 splices. Metadata is {filename, docKind}; a record may carry filename or docKind pairs {before, after}. Metadata-only acts keep equal source roots and an empty splice list. Inverses reference retained act IDs, so later metadata choices survive selective Undo. File handles and save permissions are never portable. The existing record validator remains authoritative for field bounds.
|
|
347
381
|
|
|
348
382
|
An optional `authored: {basis, source, splices}` preserves an act's original placement
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import {documentFontUsage, CSS_GENERIC_FONT_FAMILIES} from '../spec/document-fonts.mjs';
|
|
3
|
+
import {normalizeFontFamily} from '../spec/md-marks.mjs';
|
|
4
|
+
// Collect document font data without a layout engine. Authored drawing resources keep their identities.
|
|
5
|
+
const FONT_FIELDS = new Set(['font', 'font-family', 'font-weight', 'font-style', 'font-stretch', 'text-transform']);
|
|
6
|
+
const FAMILY = "'Geist',system-ui,sans-serif";
|
|
7
|
+
const MONO = "'Geist Mono',monospace";
|
|
8
|
+
|
|
9
|
+
// Optional HTML enhancement: the font payload and its encoded stylesheet have separate limits.
|
|
10
|
+
const FACTORY_FONT_BYTES = 8 * 1024, FACTORY_FONT_CSS_BYTES = 12 * 1024;
|
|
11
|
+
const decodeCss = value => value.replace(/\\(?:([\da-f]{1,6})(?:\r\n|[ \t\n\r\f])?|([^\n\r\f]))/gi,
|
|
12
|
+
(_, hex, character) => hex ? String.fromCodePoint(Math.max(1, Math.min(0x10ffff, parseInt(hex, 16)))) : character);
|
|
13
|
+
const unquote = value => decodeCss(value.trim().replace(/^(['"])([\s\S]*)\1$/, '$2'));
|
|
14
|
+
|
|
15
|
+
function splitCss(text, delimiter) {
|
|
16
|
+
const parts = [];
|
|
17
|
+
let quote = '', depth = 0, start = 0;
|
|
18
|
+
for (let at = 0; at < text.length; at++) {
|
|
19
|
+
const char = text[at];
|
|
20
|
+
if (char === '\\') { at++; continue; }
|
|
21
|
+
if (quote) { if (char === quote) quote = ''; continue; }
|
|
22
|
+
if (char === '"' || char === "'") { quote = char; continue; }
|
|
23
|
+
if (char === '(' || char === '[') depth++;
|
|
24
|
+
else if (char === ')' || char === ']') depth--;
|
|
25
|
+
else if (!depth && (delimiter === ' ' ? /\s/.test(char) : char === delimiter)) {
|
|
26
|
+
if (at > start || delimiter !== ' ') parts.push(text.slice(start, at));
|
|
27
|
+
start = at + 1;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (start < text.length || delimiter !== ' ') parts.push(text.slice(start));
|
|
31
|
+
return parts;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function declarations(text) {
|
|
35
|
+
return splitCss(text, ';').flatMap(item => {
|
|
36
|
+
const colon = item.indexOf(':');
|
|
37
|
+
if (colon < 0) return [];
|
|
38
|
+
const name = item.slice(0, colon).trim().toLowerCase(), raw = item.slice(colon + 1).trim();
|
|
39
|
+
return [{name, value: raw.replace(/\s*!important\s*$/i, ''), important: /!important\s*$/i.test(raw)}];
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function rules(text, visit) {
|
|
44
|
+
let start = 0, quote = '', parentheses = 0, depth = 0, selector = '', body = 0;
|
|
45
|
+
for (let at = 0; at < text.length; at++) {
|
|
46
|
+
const char = text[at];
|
|
47
|
+
if (char === '\\') { at++; continue; }
|
|
48
|
+
if (quote) { if (char === quote) quote = ''; continue; }
|
|
49
|
+
if (char === '/' && text[at + 1] === '*') {
|
|
50
|
+
const end = text.indexOf('*/', at + 2);
|
|
51
|
+
if (end < 0) return;
|
|
52
|
+
text = text.slice(0, at) + ' '.repeat(end + 2 - at) + text.slice(end + 2);
|
|
53
|
+
at = end + 1; continue;
|
|
54
|
+
}
|
|
55
|
+
if (char === '"' || char === "'") { quote = char; continue; }
|
|
56
|
+
if (char === '(' || char === '[') parentheses++;
|
|
57
|
+
else if (char === ')' || char === ']') parentheses--;
|
|
58
|
+
else if (!parentheses && char === '{') { if (depth++ === 0) { selector = text.slice(start, at).trim(); body = at + 1; } }
|
|
59
|
+
else if (!parentheses && char === '}') {
|
|
60
|
+
if (--depth === 0) {
|
|
61
|
+
const value = text.slice(body, at);
|
|
62
|
+
if (/^@(?:media|supports|layer|container)\b/i.test(selector)) rules(value, visit);
|
|
63
|
+
else if (!selector.startsWith('@') || /^@font-face\b/i.test(selector)) visit(selector, declarations(value));
|
|
64
|
+
start = at + 1;
|
|
65
|
+
}
|
|
66
|
+
} else if (!depth && !parentheses && char === ';') start = at + 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function specificity(selector) {
|
|
71
|
+
// The reference sheet uses :where() for zero-specificity defaults.
|
|
72
|
+
let value = selector.replace(/:where\((?:[^()]|\([^()]*\))*\)/g, '');
|
|
73
|
+
const ids = (value.match(/#[\w-]+/g) || []).length;
|
|
74
|
+
const classes = (value.match(/\.[\w-]+|\[[^\]]*\]|:(?!:)[\w-]+/g) || []).length;
|
|
75
|
+
value = value.replace(/#[\w-]+|\.[\w-]+|\[[^\]]*\]|::?[\w-]+/g, '');
|
|
76
|
+
return ids * 1000000 + classes * 1000 + (value.match(/(?:^|[\s>+~,(])[a-zA-Z][\w-]*/g) || []).length;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function resolveValue(value, variables, trail = new Set()) {
|
|
80
|
+
return value.replace(/var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*))?\)/g, (whole, name, fallback) => {
|
|
81
|
+
if (trail.has(name)) return fallback || '';
|
|
82
|
+
const found = variables[name];
|
|
83
|
+
return found == null ? fallback || '' : resolveValue(found, variables, new Set([...trail, name]));
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function fontShorthand(value) {
|
|
88
|
+
const parts = splitCss(value, ' ');
|
|
89
|
+
const size = parts.findIndex(part => /^(?:[\d.]+(?:px|pt|em|rem|%|pc|in|cm|mm|ex|ch)|(?:calc|min|max|clamp)\(|(?:xx?-small|small|medium|large|xx?-large))/.test(part));
|
|
90
|
+
if (size < 0) return {};
|
|
91
|
+
let firstFamily = size + 1;
|
|
92
|
+
if (parts[firstFamily] === '/') firstFamily += 2;
|
|
93
|
+
else if (parts[firstFamily]?.startsWith('/')) firstFamily++;
|
|
94
|
+
const prefix = parts.slice(0, size);
|
|
95
|
+
return {'font-family': parts.slice(firstFamily).join(' '), 'font-style': prefix.find(part => /^(?:italic|oblique)$/.test(part)) || 'normal',
|
|
96
|
+
'font-weight': prefix.find(part => /^(?:bold|bolder|lighter|[1-9]\d{0,3})$/.test(part)) || '400',
|
|
97
|
+
'font-stretch': prefix.find(part => /^(?:ultra-|extra-|semi-)?(?:condensed|expanded)$/.test(part)) || 'normal'};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function fontFaces(css) {
|
|
101
|
+
const faces = [];
|
|
102
|
+
rules(css, (selector, fields) => {
|
|
103
|
+
if (!/^@font-face\b/i.test(selector)) return;
|
|
104
|
+
const properties = Object.fromEntries(fields.map(row => [row.name, row.value]));
|
|
105
|
+
const family = unquote(properties['font-family'] || '').toLowerCase();
|
|
106
|
+
const match = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^\s)]*))\s*\)/i.exec(properties.src || '');
|
|
107
|
+
if (!family || !match) return;
|
|
108
|
+
const source = decodeCss(match[1] ?? match[2] ?? match[3]);
|
|
109
|
+
if (!/^data:(?:font\/(?:woff2?|ttf|otf)|application\/(?:font-woff|x-font-ttf|x-font-opentype|octet-stream));base64,[a-z\d+/]+={0,2}$/i.test(source))
|
|
110
|
+
throw Object.assign(new Error('The page font is not embedded: ' + family), {code: 'export_font_unavailable'});
|
|
111
|
+
faces.push({family, properties, source, runs: []});
|
|
112
|
+
});
|
|
113
|
+
return faces;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function weightDistance(face, desired) {
|
|
117
|
+
const weight = value => value === 'normal' ? 400 : value === 'bold' ? 700 : Number(value);
|
|
118
|
+
const range = splitCss(face.properties['font-weight'] || '400', ' ').map(weight), value = weight(desired) || 400;
|
|
119
|
+
const low = range[0] || 400, high = range[1] || low;
|
|
120
|
+
if (value >= low && value <= high) return 0;
|
|
121
|
+
const nearest = value < low ? low : high;
|
|
122
|
+
if (value >= 400 && value <= 500) return nearest >= value && nearest <= 500 ? nearest - value : nearest < value ? 1000 + value - nearest : 2000 + nearest - value;
|
|
123
|
+
return value < 400 ? nearest < value ? value - nearest : 1000 + nearest - value : nearest > value ? nearest - value : 1000 + value - nearest;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function stretchOrder(first, second, desired) {
|
|
127
|
+
const keywords = {'ultra-condensed': 50, 'extra-condensed': 62.5, condensed: 75, 'semi-condensed': 87.5,
|
|
128
|
+
normal: 100, 'semi-expanded': 112.5, expanded: 125, 'extra-expanded': 150, 'ultra-expanded': 200};
|
|
129
|
+
const width = value => Object.hasOwn(keywords, value) ? keywords[value] : parseFloat(value);
|
|
130
|
+
const target = width(desired) || 100;
|
|
131
|
+
const distance = face => {
|
|
132
|
+
const range = splitCss(face.properties['font-stretch'] || '100%', ' ').map(width);
|
|
133
|
+
const low = range[0] || 100, high = range[1] || low;
|
|
134
|
+
if (low <= target && target <= high) return [0, 0];
|
|
135
|
+
const nearest = target < low ? low : high, preferred = target <= 100 ? nearest < target : nearest > target;
|
|
136
|
+
return [preferred ? 1 : 2, Math.abs(nearest - target)];
|
|
137
|
+
};
|
|
138
|
+
const a = distance(first), b = distance(second);
|
|
139
|
+
return a[0] - b[0] || a[1] - b[1];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function styleDistance(face, style) {
|
|
143
|
+
const wanted = style.startsWith('italic') ? 'italic' : style.startsWith('oblique') ? 'oblique' : 'normal';
|
|
144
|
+
const actual = (face.properties['font-style'] || 'normal').split(/\s/)[0];
|
|
145
|
+
return ({normal: ['normal', 'oblique', 'italic'], italic: ['italic', 'oblique', 'normal'], oblique: ['oblique', 'italic', 'normal']}[wanted]).indexOf(actual);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function unicodeIncludes(face, character) {
|
|
149
|
+
const range = face.properties['unicode-range'];
|
|
150
|
+
if (!range) return true;
|
|
151
|
+
const code = character.codePointAt(0);
|
|
152
|
+
return splitCss(range, ',').some(value => {
|
|
153
|
+
const match = /^\s*u\+([\da-f?]+)(?:-([\da-f]+))?\s*$/i.exec(value);
|
|
154
|
+
if (!match) return false;
|
|
155
|
+
const from = parseInt(match[1].replace(/\?/g, '0'), 16), to = parseInt((match[2] || match[1]).replace(/\?/g, 'f'), 16);
|
|
156
|
+
return code >= from && code <= to;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function writeFace(face, bytes) {
|
|
161
|
+
const signature = String.fromCharCode(...bytes.subarray(0, 4));
|
|
162
|
+
const format = signature === 'wOFF' ? 'woff' : signature === 'wOF2' ? 'woff2' : signature === 'OTTO' ? 'opentype' : 'truetype';
|
|
163
|
+
const mime = {woff: 'woff', woff2: 'woff2', opentype: 'otf', truetype: 'ttf'}[format];
|
|
164
|
+
let binary = '';
|
|
165
|
+
for (let at = 0; at < bytes.length; at += 16384) binary += String.fromCharCode(...bytes.subarray(at, at + 16384));
|
|
166
|
+
const properties = {...face.properties, src: 'url("data:font/' + mime + ';base64,' + btoa(binary) + '") format("' + format + '")'};
|
|
167
|
+
delete properties['unicode-range'];
|
|
168
|
+
return '@font-face{' + Object.entries(properties).map(([name, value]) => name + ':' + value).join(';') + '}';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function fontRuns(root, {settings, css = '', fontCss = '', faces = fontFaces(fontCss)}) {
|
|
172
|
+
if (!root?.querySelectorAll) throw new TypeError('The page font writer needs a document.');
|
|
173
|
+
if (!faces.length) return faces;
|
|
174
|
+
const selectors = [];
|
|
175
|
+
const addRules = text => rules(text, (selector, fields) => {
|
|
176
|
+
if (selector.startsWith('@')) return;
|
|
177
|
+
const kept = fields.filter(row => FONT_FIELDS.has(row.name) || row.name.startsWith('--'));
|
|
178
|
+
if (kept.length) for (const selectorPart of splitCss(selector, ',')) selectors.push({selector: selectorPart.trim(), fields: kept, rank: specificity(selectorPart)});
|
|
179
|
+
});
|
|
180
|
+
addRules(css);
|
|
181
|
+
for (const style of root.querySelectorAll('style:not([data-rapier-font])')) addRules(style.textContent || '');
|
|
182
|
+
const oldClass = root.getAttribute('class');
|
|
183
|
+
root.setAttribute('class', (oldClass ? oldClass + ' ' : '') + 'md-render rapier-page');
|
|
184
|
+
try {
|
|
185
|
+
const initial = {'font-family': settings?.mainfont?.css || FAMILY, 'font-weight': '400', 'font-style': 'normal', 'font-stretch': 'normal',
|
|
186
|
+
'text-transform': 'none', variables: {'--md-font-sans': settings?.sansfont?.css || settings?.mainfont?.css || FAMILY,
|
|
187
|
+
'--md-font-main': settings?.mainfont?.css || settings?.sansfont?.css || FAMILY,
|
|
188
|
+
'--md-font-mono': settings?.monofont?.css || MONO}};
|
|
189
|
+
const computed = new WeakMap();
|
|
190
|
+
let priorFace = null, priorStyle = '';
|
|
191
|
+
const breakRun = () => { priorFace = null; priorStyle = ''; };
|
|
192
|
+
function styleOf(element) {
|
|
193
|
+
if (computed.has(element)) return computed.get(element);
|
|
194
|
+
const parent = element === root ? initial : styleOf(element.parentElement || root);
|
|
195
|
+
const state = {...parent, variables: Object.create(parent.variables)}, chosen = new Map();
|
|
196
|
+
let order = 0;
|
|
197
|
+
function take(row, rank) {
|
|
198
|
+
const score = (row.important ? 1e12 : 0) + rank, prior = chosen.get(row.name);
|
|
199
|
+
order++;
|
|
200
|
+
if (!prior || score >= prior.score) chosen.set(row.name, {...row, score, order});
|
|
201
|
+
}
|
|
202
|
+
for (const field of FONT_FIELDS) if (element.hasAttribute(field)) take({name: field, value: element.getAttribute(field)}, -1);
|
|
203
|
+
for (const rule of selectors) {
|
|
204
|
+
let matches = false;
|
|
205
|
+
try { matches = element.matches(rule.selector); } catch (_) {}
|
|
206
|
+
if (matches) for (const row of rule.fields) take(row, rule.rank);
|
|
207
|
+
}
|
|
208
|
+
for (const row of declarations(element.getAttribute('style') || '')) if (FONT_FIELDS.has(row.name) || row.name.startsWith('--')) take(row, 1e9);
|
|
209
|
+
for (const row of chosen.values()) if (row.name.startsWith('--')) state.variables[row.name] = row.value;
|
|
210
|
+
const shorthand = chosen.get('font');
|
|
211
|
+
if (shorthand) for (const [name, value] of Object.entries(fontShorthand(resolveValue(shorthand.value, state.variables)))) {
|
|
212
|
+
const longhand = chosen.get(name);
|
|
213
|
+
if (!longhand || longhand.score < shorthand.score || longhand.score === shorthand.score && longhand.order <= shorthand.order)
|
|
214
|
+
chosen.set(name, {...shorthand, name, value});
|
|
215
|
+
}
|
|
216
|
+
for (const field of FONT_FIELDS) {
|
|
217
|
+
if (field === 'font' || !chosen.has(field)) continue;
|
|
218
|
+
const value = resolveValue(chosen.get(field).value, state.variables);
|
|
219
|
+
if (value && value !== 'inherit' && value !== 'unset') state[field] = value;
|
|
220
|
+
}
|
|
221
|
+
if (state['font-weight'] === 'bolder') state['font-weight'] = Number(parent['font-weight']) >= 600 ? '900' : '700';
|
|
222
|
+
if (state['font-weight'] === 'lighter') state['font-weight'] = Number(parent['font-weight']) >= 600 ? '400' : '100';
|
|
223
|
+
computed.set(element, state); return state;
|
|
224
|
+
}
|
|
225
|
+
function add(text, element, overrides) {
|
|
226
|
+
if (!text || !text.trim() && !element.closest('pre,code,svg text,svg tspan')) { breakRun(); return; }
|
|
227
|
+
const state = {...styleOf(element), ...overrides};
|
|
228
|
+
if (state['text-transform'] === 'uppercase') text = text.toLocaleUpperCase(element.closest('[lang]')?.getAttribute('lang') || 'en');
|
|
229
|
+
else if (state['text-transform'] === 'lowercase') text = text.toLocaleLowerCase(element.closest('[lang]')?.getAttribute('lang') || 'en');
|
|
230
|
+
else if (state['text-transform'] === 'capitalize') text = text.replace(/(^|\s)(\p{L})/gu, (_, gap, letter) => gap + letter.toUpperCase());
|
|
231
|
+
const families = splitCss(state['font-family'], ',').filter(name => /^\s*['"]/.test(name) || !CSS_GENERIC_FONT_FAMILIES.includes(unquote(name).toLowerCase()))
|
|
232
|
+
.map(name => unquote(name).toLowerCase());
|
|
233
|
+
const runStyle = [state['font-weight'], state['font-style'], state['font-stretch']].join('/');
|
|
234
|
+
for (const character of text) {
|
|
235
|
+
let selected = null;
|
|
236
|
+
for (const family of families) {
|
|
237
|
+
const candidates = faces.filter(face => face.family === family && unicodeIncludes(face, character));
|
|
238
|
+
if (!candidates.length) continue;
|
|
239
|
+
candidates.sort((a, b) => stretchOrder(a, b, state['font-stretch']) ||
|
|
240
|
+
styleDistance(a, state['font-style']) - styleDistance(b, state['font-style']) ||
|
|
241
|
+
weightDistance(a, state['font-weight']) - weightDistance(b, state['font-weight']));
|
|
242
|
+
selected = candidates[0]; break;
|
|
243
|
+
}
|
|
244
|
+
if (!selected) { breakRun(); continue; }
|
|
245
|
+
if (selected === priorFace && runStyle === priorStyle) selected.runs[selected.runs.length - 1] += character;
|
|
246
|
+
else selected.runs.push(character);
|
|
247
|
+
priorFace = selected; priorStyle = runStyle;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function walk(element) {
|
|
251
|
+
const name = element.localName?.toLowerCase();
|
|
252
|
+
if (['script', 'style', 'metadata', 'title', 'desc', 'defs', 'template'].includes(name) || element.hasAttribute('hidden') ||
|
|
253
|
+
element.classList?.contains('diagram-source') && element.closest('[data-diagram-state="ready"]') ||
|
|
254
|
+
element.classList?.contains('rapier-image-asset-record')) return;
|
|
255
|
+
const boundary = /^(?:address|article|aside|blockquote|br|dd|details|div|dl|dt|fieldset|figcaption|figure|footer|form|h[1-6]|header|hr|li|main|nav|ol|p|pre|section|summary|table|td|text|th|tr|ul)$/.test(name);
|
|
256
|
+
if (boundary) breakRun();
|
|
257
|
+
if (name === 'input' && !/^(?:checkbox|radio|hidden|range|color|file)$/i.test(element.getAttribute('type') || 'text')) {
|
|
258
|
+
breakRun(); add(element.getAttribute('value') || element.getAttribute('placeholder') || '', element); breakRun();
|
|
259
|
+
}
|
|
260
|
+
if (element.hasAttribute('data-md-break')) { breakRun(); add('page', element); breakRun(); }
|
|
261
|
+
if (name === 'ol') {
|
|
262
|
+
const native = element.classList.contains('footnotes-list');
|
|
263
|
+
let ordinal = Number(element.getAttribute('start')) || 1;
|
|
264
|
+
for (const item of element.children) if (item.localName?.toLowerCase() === 'li') {
|
|
265
|
+
if (item.hasAttribute('value')) ordinal = Number(item.getAttribute('value'));
|
|
266
|
+
breakRun();
|
|
267
|
+
add(String(ordinal++) + (native ? '. ' : ''), item, native ? undefined :
|
|
268
|
+
{'font-family': resolveValue('var(--md-font-sans)', styleOf(item).variables), 'font-weight': resolveValue('var(--md-fw-semibold)', styleOf(item).variables)});
|
|
269
|
+
breakRun();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
for (const child of element.childNodes) {
|
|
273
|
+
if (child.nodeType === 3) add(child.nodeValue || '', element);
|
|
274
|
+
else if (child.nodeType === 1) walk(child);
|
|
275
|
+
}
|
|
276
|
+
if (boundary) breakRun();
|
|
277
|
+
}
|
|
278
|
+
walk(root);
|
|
279
|
+
} finally {
|
|
280
|
+
if (oldClass == null) root.removeAttribute('class'); else root.setAttribute('class', oldClass);
|
|
281
|
+
}
|
|
282
|
+
return faces.filter(face => face.runs.length);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function exportFontCss(root, {settings, css = '', fontCss = '', subset, validateFont, portableFactory = false, customFontCss = ''}) {
|
|
286
|
+
try {
|
|
287
|
+
if (typeof subset !== 'function') throw new TypeError('The page font writer needs a subsetter.');
|
|
288
|
+
let candidates = fontFaces(fontCss);
|
|
289
|
+
if (portableFactory) {
|
|
290
|
+
// These candidates come from the factory resource host. Carried data owns a same-name family.
|
|
291
|
+
const custom = new Set(fontFaces(customFontCss).map(face => face.family));
|
|
292
|
+
candidates = candidates.filter(face => face.family === 'geist' && !custom.has(face.family));
|
|
293
|
+
}
|
|
294
|
+
const faces = fontRuns(root, {settings, css, faces: candidates}), output = [];
|
|
295
|
+
let payloadBytes = 0;
|
|
296
|
+
for (const face of faces) {
|
|
297
|
+
const original = Uint8Array.from(atob(face.source.slice(face.source.indexOf(',') + 1)), character => character.charCodeAt(0));
|
|
298
|
+
// Adjacent inline nodes retain shaping sequences; independent runs cannot compose together.
|
|
299
|
+
const bytes = await subset(original, face.runs.join('\n'));
|
|
300
|
+
if (portableFactory) {
|
|
301
|
+
if (!(bytes instanceof Uint8Array) || !bytes.length || bytes.length >= original.length) return '';
|
|
302
|
+
payloadBytes += bytes.length;
|
|
303
|
+
if (payloadBytes > FACTORY_FONT_BYTES) return '';
|
|
304
|
+
if (typeof validateFont !== 'function' || await validateFont(Uint8Array.from(bytes), {family: 'Geist'}) !== true) return '';
|
|
305
|
+
} else if (bytes === null) continue;
|
|
306
|
+
if (!(bytes instanceof Uint8Array) || !bytes.length) throw new Error('The page font subset is empty.');
|
|
307
|
+
output.push(writeFace(face, bytes));
|
|
308
|
+
}
|
|
309
|
+
const result = output.join('\n');
|
|
310
|
+
return portableFactory && new TextEncoder().encode(result).length > FACTORY_FONT_CSS_BYTES ? '' : result;
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (!portableFactory || error?.name === 'AbortError' || error?.code === 'cancelled') throw error;
|
|
313
|
+
return '';
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Validate the captured custom resources without rewriting their source or borrowing a live catalog.
|
|
318
|
+
export async function requireDocumentFonts(source, {parser, index, root, settings, css = '', fontCss = '', validateFont}) {
|
|
319
|
+
const candidates = fontFaces(fontCss), known = new Set(candidates.map(face => face.family));
|
|
320
|
+
// Missing named choices participate in the same cascade, but inert and generic runs need no data.
|
|
321
|
+
for (const family of ['Geist', 'Geist Mono', ...documentFontUsage(source, parser).keys()]) {
|
|
322
|
+
const name = family.toLowerCase();
|
|
323
|
+
if (!known.has(name)) {
|
|
324
|
+
candidates.push({family: name, properties: {}, runs: []});
|
|
325
|
+
known.add(name);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const used = new Set(fontRuns(root, {settings, css, faces: candidates}).map(face => face.family));
|
|
329
|
+
const checked = new Set(), Font = root.ownerDocument?.defaultView?.FontFace || globalThis.FontFace;
|
|
330
|
+
for (const family of used) {
|
|
331
|
+
const assets = index.assets.filter(asset => asset.family.toLowerCase() === family);
|
|
332
|
+
if (!assets.length) {
|
|
333
|
+
const factory = family === 'geist' || family === 'geist mono';
|
|
334
|
+
const declared = Object.entries(index.references).some(([label, definition]) => {
|
|
335
|
+
if (!label.startsWith('FONT:')) return false;
|
|
336
|
+
if (label === 'FONT:' + family.toUpperCase() || label.startsWith('FONT:' + family.toUpperCase() + '/')) return true;
|
|
337
|
+
try { return normalizeFontFamily(JSON.parse(definition.title).family)?.toLowerCase() === family; }
|
|
338
|
+
catch (_) { return false; }
|
|
339
|
+
});
|
|
340
|
+
if (!factory || declared)
|
|
341
|
+
throw Object.assign(new Error('The selected font is not embedded: ' + family), {code: 'export_font_unavailable'});
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
for (const asset of assets) {
|
|
345
|
+
if (checked.has(asset.url)) continue;
|
|
346
|
+
const bytes = Uint8Array.from(atob(asset.data), character => character.charCodeAt(0));
|
|
347
|
+
if (typeof validateFont !== 'function')
|
|
348
|
+
throw Object.assign(new Error('Embedded font validation is unavailable: ' + asset.family), {code: 'export_font_unavailable'});
|
|
349
|
+
if (await validateFont(bytes, {family: asset.family, format: asset.format}) !== true)
|
|
350
|
+
throw Object.assign(new Error('The embedded font is invalid: ' + asset.family), {code: 'export_font_invalid'});
|
|
351
|
+
// The host validates the font; the viewing engine must also accept this exact container.
|
|
352
|
+
// Loading an uninstalled face checks bytes without changing this document's font set.
|
|
353
|
+
if (typeof Font === 'function') try { await new Font('rapier-export-validation', bytes).load(); }
|
|
354
|
+
catch (cause) { throw Object.assign(new Error('The embedded font could not be loaded: ' + asset.family, {cause}), {code: 'export_font_invalid'}); }
|
|
355
|
+
checked.add(asset.url);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function fontRequests(root, options = {}) {
|
|
361
|
+
return fontRuns(root, options).map(record => ({family: unquote(record.properties['font-family']),
|
|
362
|
+
style: record.properties['font-style'] || 'normal', weight: record.properties['font-weight'] || '400',
|
|
363
|
+
stretch: record.properties['font-stretch'] || '100%'}));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// This self-contained readiness step also runs in the isolated server print document.
|
|
367
|
+
export async function waitForFontRequests(doc, requests) {
|
|
368
|
+
if (!requests?.length) return;
|
|
369
|
+
const family = value => String(value).trim().replace(/^(['"])([\s\S]*)\1$/, '$2').replace(/\\(?:([\da-f]{1,6})\s?|([^\r\n\f]))/gi,
|
|
370
|
+
(_, hex, character) => hex ? String.fromCodePoint(Math.max(1, Math.min(0x10ffff, parseInt(hex, 16)))) : character).toLowerCase();
|
|
371
|
+
const weight = value => String(value || '400').trim().replace(/\s+/g, ' ').replace(/^normal$/, '400').replace(/^bold$/, '700');
|
|
372
|
+
const stretch = value => String(value || '100%').trim().replace(/\s+/g, ' ').replace(/^normal$/, '100%');
|
|
373
|
+
for (const request of requests) {
|
|
374
|
+
const faces = Array.from(doc.fonts || []).filter(face => family(face.family) === request.family.toLowerCase() &&
|
|
375
|
+
face.style === request.style && weight(face.weight) === weight(request.weight) && stretch(face.stretch) === stretch(request.stretch));
|
|
376
|
+
try {
|
|
377
|
+
if (!faces.length) throw new Error('The font is missing from the print page.');
|
|
378
|
+
await Promise.all(faces.map(face => face.load()));
|
|
379
|
+
} catch (cause) { throw Object.assign(new Error('The embedded font could not be loaded: ' + request.family, {cause}), {code: 'export_font_unavailable'}); }
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// CSS font loading can finish successfully while a required face is in the error state.
|
|
384
|
+
// Load the exact embedded bytes before print takes its immutable page, and leave unrelated
|
|
385
|
+
// local fallbacks and unused family styles outside this document's readiness decision.
|
|
386
|
+
export async function waitForFonts(root, {settings, css = '', fontCss = '', install = true} = {}) {
|
|
387
|
+
const required = fontRuns(root, {settings, css, fontCss});
|
|
388
|
+
if (!required.length) return () => {};
|
|
389
|
+
const doc = root.ownerDocument, Font = doc?.defaultView?.FontFace || globalThis.FontFace;
|
|
390
|
+
if (typeof Font !== 'function' || !doc?.fonts)
|
|
391
|
+
throw Object.assign(new Error('Embedded document fonts cannot be loaded for printing.'), {code: 'export_font_unavailable'});
|
|
392
|
+
const loaded = await Promise.all(required.map(async record => {
|
|
393
|
+
const family = unquote(record.properties['font-family']);
|
|
394
|
+
const descriptor = {};
|
|
395
|
+
for (const [css, name] of [['font-style', 'style'], ['font-weight', 'weight'], ['font-stretch', 'stretch'], ['unicode-range', 'unicodeRange']])
|
|
396
|
+
if (record.properties[css]) descriptor[name] = record.properties[css];
|
|
397
|
+
const bytes = Uint8Array.from(atob(record.source.slice(record.source.indexOf(',') + 1)), character => character.charCodeAt(0));
|
|
398
|
+
try { return await new Font(record.properties['font-family'], bytes, descriptor).load(); }
|
|
399
|
+
catch (cause) { throw Object.assign(new Error('The embedded font could not be loaded: ' + family, {cause}), {code: 'export_font_unavailable'}); }
|
|
400
|
+
}));
|
|
401
|
+
if (install) for (const face of loaded) doc.fonts.add(face);
|
|
402
|
+
return () => { if (install) for (const face of loaded) doc.fonts.delete(face); };
|
|
403
|
+
}
|
package/dist/kit/ledger/data.mjs
CHANGED
|
@@ -27,7 +27,7 @@ export function exactKeys(value, keys) {
|
|
|
27
27
|
}
|
|
28
28
|
export function documentText(text) {
|
|
29
29
|
if (typeof text !== 'string' || /[\uD800-\uDFFF]/u.test(text)) throw fault('document is not whole-character text');
|
|
30
|
-
if (new TextEncoder().encode(text).length >
|
|
30
|
+
if (new TextEncoder().encode(text).length > 64 * 1024 * 1024) throw fault('document exceeds the editor text limit');
|
|
31
31
|
return text;
|
|
32
32
|
}
|
|
33
33
|
export const nonnegative = value => Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
import {_rapierValidLedgerRecord, _rapierRecordSplices, _rapierTransformSplices, _rapierRecordMetadata, _rapierValidMetadata, _rapierTransformMetadata, _rapierReplayMetadata} from './journal-records.mjs';
|
|
2
|
+
import {_rapierValidLedgerRecord, _rapierRecordSplices, _rapierTransformSplices, _rapierRecordMetadata, _rapierValidMetadata, _rapierTransformMetadata, _rapierReplayMetadata, _rapierHistoryEffects} from './journal-records.mjs';
|
|
3
|
+
import {pictureHistorySplices} from './history.mjs';
|
|
3
4
|
import {authoredHistory} from './transport.mjs';
|
|
4
5
|
import {textRoot, rootAfter, sha256} from './hash.mjs';
|
|
5
6
|
import {canonicalJSON, plainData, documentText, exactKeys, nonnegative, fault} from './data.mjs';
|
|
@@ -21,7 +22,7 @@ export function readLedger(input, expectedText, expectedMetadata) {
|
|
|
21
22
|
const earlier = [];
|
|
22
23
|
for (const record of ledger.records) {
|
|
23
24
|
if (!Array.isArray(record.splices) || !_rapierValidLedgerRecord(record, earlier.at(-1) || null, earlier,
|
|
24
|
-
ledger.documentAuthority,
|
|
25
|
+
ledger.documentAuthority, 64 * 1024 * 1024, text => new TextEncoder().encode(text).length, !ledger.complete) ||
|
|
25
26
|
record.transaction.baseRevision < revision || (record.transaction.baseRevision !== revision && ledger.complete) ||
|
|
26
27
|
(record.transaction.baseRevision === revision && record.beforeHash !== root)) throw fault('record chain differs');
|
|
27
28
|
// A pruned cancelling pair leaves a stated revision gap, just as in the editor's restore reader.
|
|
@@ -77,8 +78,10 @@ export function exportLedger({text, metadata, records, documentAuthority, revisi
|
|
|
77
78
|
// Derived navigation, never a second history. A target older than start is intentionally unavailable.
|
|
78
79
|
export function historyEnvelope(input, segmentIdentity = []) {
|
|
79
80
|
const {ledger, text, metadata} = readLedger(input), ids = new Set(ledger.records.map(row => row.transaction.id));
|
|
81
|
+
const pictures = _rapierHistoryEffects(ledger.records).pictures;
|
|
80
82
|
let branch = [], cursor = 0;
|
|
81
83
|
for (const {transaction: tx} of ledger.records) {
|
|
84
|
+
if (pictures.has(tx.id)) continue;
|
|
82
85
|
const target = tx.reverts || tx.reapplies;
|
|
83
86
|
if (target) {
|
|
84
87
|
if (!ids.has(target)) continue;
|
|
@@ -88,15 +91,19 @@ export function historyEnvelope(input, segmentIdentity = []) {
|
|
|
88
91
|
} else { branch = branch.slice(0, cursor); branch.push(tx.id); cursor = branch.length; }
|
|
89
92
|
}
|
|
90
93
|
const byId = new Map(ledger.records.map(row => [row.transaction.id, row]));
|
|
94
|
+
const step = (source, id, redo) => {
|
|
95
|
+
const target = byId.get(id), folded = pictureHistorySplices({source, records: ledger.records}, target, redo);
|
|
96
|
+
return _rapierTransformSplices(source, folded || target.splices, !folded && !redo);
|
|
97
|
+
};
|
|
91
98
|
let traversed = text, traversedMetadata = {...metadata};
|
|
92
99
|
for (let i = cursor - 1; i >= 0; i--) {
|
|
93
|
-
traversed =
|
|
100
|
+
traversed = step(traversed, branch[i], false);
|
|
94
101
|
traversedMetadata = _rapierTransformMetadata(traversedMetadata, byId.get(branch[i]).metadata, true);
|
|
95
102
|
if (traversed === null || !traversedMetadata) throw fault('undo branch does not reverse');
|
|
96
103
|
}
|
|
97
104
|
traversed = text; traversedMetadata = {...metadata};
|
|
98
105
|
for (let i = cursor; i < branch.length; i++) {
|
|
99
|
-
traversed =
|
|
106
|
+
traversed = step(traversed, branch[i], true);
|
|
100
107
|
traversedMetadata = _rapierTransformMetadata(traversedMetadata, byId.get(branch[i]).metadata);
|
|
101
108
|
if (traversed === null || !traversedMetadata) throw fault('redo branch does not replay');
|
|
102
109
|
}
|
|
@@ -105,7 +112,7 @@ export function historyEnvelope(input, segmentIdentity = []) {
|
|
|
105
112
|
let active = ledger.start.text, activeMetadata = {...ledger.start.metadata};
|
|
106
113
|
if (!cursor && (active !== text || canonicalJSON(activeMetadata) !== canonicalJSON(metadata))) throw fault('complete history start differs');
|
|
107
114
|
for (let i = 0; i < branch.length; i++) {
|
|
108
|
-
active =
|
|
115
|
+
active = step(active, branch[i], true);
|
|
109
116
|
activeMetadata = _rapierTransformMetadata(activeMetadata, byId.get(branch[i]).metadata);
|
|
110
117
|
if (active === null || !activeMetadata || (i + 1 === cursor && (active !== text || canonicalJSON(activeMetadata) !== canonicalJSON(metadata)))) throw fault('complete branch differs');
|
|
111
118
|
}
|