rapier-markdown-kit 1.2.7 → 1.2.8
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 +32 -30
- package/dist/agent/will.mjs +2 -1
- package/dist/images/playback.mjs +3 -2
- package/dist/kit/index.mjs +1 -1
- package/dist/kit/ledger/carried.d.mts +20 -0
- package/dist/kit/ledger.d.mts +88 -0
- package/dist/kit/ledger.mjs +18 -0
- package/dist/kit/markdown-it.d.mts +4 -0
- package/dist/kit/markdown-it.mjs +39 -0
- package/dist/kit/pdf-glyphs.mjs +43 -40
- package/dist/kit/pdf-source.d.mts +5 -0
- package/dist/kit/pdf-source.mjs +125 -0
- package/dist/kit/render-markdown.mjs +3 -2
- package/dist/kit/render-print.mjs +3 -2
- package/dist/kit/render-sanitize.mjs +21 -5
- package/dist/kit/will.d.mts +42 -0
- package/dist/spec/document-settings.mjs +2 -2
- package/dist/spec/html-blocks.mjs +4 -2
- package/package.json +21 -4
- package/style.css +6 -5
package/README.md
CHANGED
|
@@ -24,6 +24,28 @@ deliberately show a diff. The kit supplies the portable source and history those
|
|
|
24
24
|
npm install rapier-markdown-kit
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
Render a document with [markdown-it](https://github.com/markdown-it/markdown-it), then style it with
|
|
28
|
+
`rapier-markdown-kit/style.css` inside `<main class="md-render">`:
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
import MarkdownIt from 'markdown-it';
|
|
32
|
+
import {rapierMarkdown} from 'rapier-markdown-kit/markdown-it';
|
|
33
|
+
|
|
34
|
+
const html = new MarkdownIt({html: true}).use(rapierMarkdown).render('# Plan\n\nShips **Friday**.\n');
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Keep a document's history with authorship, and merge two offline copies:
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
import {openLedger, replaceLedgerText, merge, authorship} from 'rapier-markdown-kit/rapier-ledger';
|
|
41
|
+
|
|
42
|
+
const base = openLedger('Draft\n');
|
|
43
|
+
const edited = replaceLedgerText(base, 'Draft, revised\n', {actor: {kind: 'human', id: 'ana'}});
|
|
44
|
+
console.log(merge(edited, base).text, authorship(edited).runs);
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
TypeScript declarations ship for `markdown-it`, `will`, `rapier-ledger`, `ledger/carried` and `pdf`.
|
|
48
|
+
|
|
27
49
|
```js
|
|
28
50
|
import {parseLayout} from 'rapier-markdown-kit/layout'; // the md-layout comment a picture carries
|
|
29
51
|
import {typography, formatPageBreak} from 'rapier-markdown-kit/marks'; // inline typography and page breaks
|
|
@@ -46,6 +68,9 @@ as one HTML file on Android, Web and Windows.
|
|
|
46
68
|
|
|
47
69
|
## The shared renderer
|
|
48
70
|
|
|
71
|
+
To render Markdown in your own app, use `rapier-markdown-kit/markdown-it` (above). The render factories below are the
|
|
72
|
+
editor's own seam: each needs a `host` that supplies its DOM, Markdown, picture, style and font ports.
|
|
73
|
+
|
|
49
74
|
`rapier-markdown-kit/render` exports `createRenderer`, `createMarkdownRenderer`,
|
|
50
75
|
`createRenderStyles`, `createRenderSanitizer` and `createPrintRenderer`, plus the explicit font helpers
|
|
51
76
|
`exportFontCss`, `requireDocumentFonts`, `fontRequests`, `waitForFonts` and `waitForFontRequests`. These are the
|
|
@@ -139,6 +164,7 @@ family name share the host's font namespace. Print retains its existing factory
|
|
|
139
164
|
|
|
140
165
|
| Import | What it is |
|
|
141
166
|
| --- | --- |
|
|
167
|
+
| `rapier-markdown-kit/markdown-it` | `md.use(rapierMarkdown)`: the reference style's markup in markdown-it, in one line. |
|
|
142
168
|
| `rapier-markdown-kit/layout` | The `md-layout:v1` comment a picture carries: parse it, validate it, write it back. |
|
|
143
169
|
| `rapier-markdown-kit/marks` | Unified typography values, span writer and parser installer; preserved color/font comments, ink and page breaks. |
|
|
144
170
|
| `rapier-markdown-kit/model` | Occupancy profiles and the line planner: the wrapping itself. |
|
|
@@ -146,7 +172,8 @@ family name share the host's font namespace. Print retains its existing factory
|
|
|
146
172
|
| `rapier-markdown-kit/will` | The Will/1 grammar (a separate, optional standard): the markers around an agent's intent regions. |
|
|
147
173
|
| `rapier-markdown-kit/assets` | The picture appendix: the reference definitions a document's pictures live in. It never decodes a picture. |
|
|
148
174
|
| `rapier-markdown-kit/style.css` | The reference style: type, lists, tables, tasks, pictures and the document's other markup, scoped to `.md-render`. |
|
|
149
|
-
| `rapier-markdown-kit/rapier-ledger` | The MIT edit
|
|
175
|
+
| `rapier-markdown-kit/rapier-ledger` | The MIT edit ledger: `openLedger`, replay, authorship, selective Undo, offline `merge`, three-way `mergeText`, and the carried parts. |
|
|
176
|
+
| `rapier-markdown-kit/pdf` | `attachPdfSource`: attach the exact source to a PDF without rewriting its pages; `readPdfPageCount`. |
|
|
150
177
|
| `rapier-markdown-kit/ledger/carried` | Checked optional parts for pages and saved Markdown. |
|
|
151
178
|
| `rapier-markdown-kit` | Named exports from the layout, marks, model, typography, Will, assets and render modules, plus the `ledger` namespace. |
|
|
152
179
|
|
|
@@ -290,38 +317,13 @@ contract is [Reference style](https://github.com/jackskip22/rapier/blob/main/doc
|
|
|
290
317
|
<!-- reference-style-example -->
|
|
291
318
|
```js
|
|
292
319
|
import MarkdownIt from 'markdown-it';
|
|
293
|
-
import {
|
|
294
|
-
import {
|
|
295
|
-
import {
|
|
320
|
+
import {rapierMarkdown} from 'rapier-markdown-kit/markdown-it';
|
|
321
|
+
import {formatLayout} from 'rapier-markdown-kit/layout';
|
|
322
|
+
import {TEXT_COLOR_NAMES, typography, formatColorRun, formatPageBreak} from 'rapier-markdown-kit/marks';
|
|
296
323
|
import {readFile, writeFile} from 'node:fs/promises';
|
|
297
324
|
import {pathToFileURL} from 'node:url';
|
|
298
325
|
|
|
299
|
-
const md =
|
|
300
|
-
md.renderer.rules.table_open = (tokens, index, options, env, renderer) =>
|
|
301
|
-
'<div class="table-scroll-wrap">\n' + renderer.renderToken(tokens, index, options);
|
|
302
|
-
md.renderer.rules.table_close = (tokens, index, options, env, renderer) =>
|
|
303
|
-
renderer.renderToken(tokens, index, options) + '</div>\n';
|
|
304
|
-
md.core.ruler.after('inline', 'reference-layout', state => {
|
|
305
|
-
for (let i = 1; i < state.tokens.length; i++) {
|
|
306
|
-
const inline = state.tokens[i], owner = state.tokens[i - 1];
|
|
307
|
-
if (inline.type !== 'inline' || !['paragraph_open', 'heading_open'].includes(owner.type)) continue;
|
|
308
|
-
const children = inline.children, marker = children.at(-1);
|
|
309
|
-
const layout = marker?.type === 'html_inline' ? parseLayout(marker.content) : null;
|
|
310
|
-
if (!layout) continue;
|
|
311
|
-
children.pop();
|
|
312
|
-
if (layout.align) owner.attrSet('data-md-align', layout.align);
|
|
313
|
-
const visible = children.filter(token => token.type !== 'text' || token.content.trim());
|
|
314
|
-
if (visible.length === 1 && visible[0].type === 'image' && layout.width) {
|
|
315
|
-
visible[0].attrSet('style', imageStyle(layout));
|
|
316
|
-
visible[0].attrSet('data-md-image-width', String(layout.width));
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
});
|
|
320
|
-
const htmlBlock = md.renderer.rules.html_block;
|
|
321
|
-
md.renderer.rules.html_block = (tokens, index, options, env, renderer) =>
|
|
322
|
-
isPageBreakBlock(tokens[index].content)
|
|
323
|
-
? '<div class="rapier-page-break" data-md-break="page" role="separator" aria-label="Page break"></div>\n'
|
|
324
|
-
: htmlBlock(tokens, index, options, env, renderer);
|
|
326
|
+
const md = new MarkdownIt({html: true, linkify: true}).use(rapierMarkdown);
|
|
325
327
|
|
|
326
328
|
export function renderReferenceDocument(source) {
|
|
327
329
|
return md.render(source);
|
package/dist/agent/will.mjs
CHANGED
|
@@ -10,7 +10,8 @@ const _RAPIER_WILL_UNSPACED_OPENER = '<!--will/';
|
|
|
10
10
|
const _RAPIER_WILL_UNSPACED_CLOSER = '<!--/will';
|
|
11
11
|
|
|
12
12
|
function hasWillMarkers(text) {
|
|
13
|
-
|
|
13
|
+
// Lines end at \r and \n only, as the parser splits them; /m would also start a line after U+2028 and U+2029.
|
|
14
|
+
return /(?:^|[\r\n])<!-- ?(?:will\/|\/will)/.test(String(text == null ? '' : text));
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
function _rapierWillMarkerOf(content) {
|
package/dist/images/playback.mjs
CHANGED
|
@@ -73,8 +73,9 @@ export function createMediaPlayback(win, inspectGIF) {
|
|
|
73
73
|
if (canvas.height !== h) canvas.height = h;
|
|
74
74
|
const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, w, h); ctx.drawImage(frame, 0, 0);
|
|
75
75
|
if (!state.first && next === 0) state.first = canvas.toDataURL('image/png');
|
|
76
|
-
// A frame of 10 ms or less shows for 100 ms, as the browser draws an <img> (images/gif.mjs counts the same).
|
|
77
|
-
|
|
76
|
+
// A GIF frame of 10 ms or less shows for 100 ms, as the browser draws an <img> (images/gif.mjs counts the same).
|
|
77
|
+
// A JPEG XL frame keeps its own duration: its ticks are exact, and 100 frames a second is a real animation.
|
|
78
|
+
const delay = type !== 'image/gif' ? Math.max(8, frame.duration / 1000) : frame.duration > 10000 ? Math.max(20, frame.duration / 1000) : 100;
|
|
78
79
|
duration += delay; if (duration > 120000) throw new Error('Animation exceeds the duration budget');
|
|
79
80
|
state.status = 'ready'; state.frame = next; changed(state);
|
|
80
81
|
if (single) {paused = true; closeDecoder(); return;}
|
package/dist/kit/index.mjs
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// The ledger and authorship carried inside the document: an optional HTML comment at the end of the file.
|
|
3
|
+
import type {LedgerEnvelope, AuthorshipEnvelope, Metadata} from '../ledger.mjs';
|
|
4
|
+
export interface DocumentParts { ledger?: LedgerEnvelope | null; authorship?: AuthorshipEnvelope | null; metadata?: Metadata; }
|
|
5
|
+
export interface ReadParts { text: string; ledger: LedgerEnvelope | null; authorship: AuthorshipEnvelope | null; }
|
|
6
|
+
/** The text with its parts appended as one EOF comment; the text alone when there are none. */
|
|
7
|
+
export function writeDocument(text: string, parts?: DocumentParts): string;
|
|
8
|
+
/** Splits a saved file into its exact text and verified parts; throws on a damaged or mismatched comment. */
|
|
9
|
+
export function readDocument(source: string): ReadParts;
|
|
10
|
+
export function writeParts(text: string, parts?: DocumentParts): string;
|
|
11
|
+
export function readParts(text: string, elements: unknown): {ledger: LedgerEnvelope | null; authorship: AuthorshipEnvelope | null};
|
|
12
|
+
export function validateParts(text: string, parts?: DocumentParts): {ledger: LedgerEnvelope | null; authorship: AuthorshipEnvelope | null};
|
|
13
|
+
export function takeParts(html: string, text?: string): {html: string; ledger: LedgerEnvelope | null; authorship: AuthorshipEnvelope | null};
|
|
14
|
+
export function encodeCarried(text: string): string;
|
|
15
|
+
export function decodeCarried(text: string): string;
|
|
16
|
+
export interface ComparisonBase { text: string; sha256: string; revision: string; name: string; by: string; at: string; }
|
|
17
|
+
export function readBase(value: Partial<ComparisonBase>): ComparisonBase;
|
|
18
|
+
export function writeBase(text: string, value: Partial<ComparisonBase>): string;
|
|
19
|
+
export function readBaseElements(elements: unknown): ComparisonBase | null;
|
|
20
|
+
export function takeBase(html: string): {html: string; base: ComparisonBase | null};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// rapier-markdown-kit/rapier-ledger: a document's edit history with authorship, Undo of any act and offline merge.
|
|
3
|
+
// Offsets count UTF-16 code units; splices run in their recorded order.
|
|
4
|
+
export type ActorKind = 'human' | 'agent' | 'system';
|
|
5
|
+
export interface Actor { kind: ActorKind; id: string; }
|
|
6
|
+
export type DocKind = 'markdown' | 'text' | 'code';
|
|
7
|
+
export interface Metadata { filename: string; docKind: DocKind; }
|
|
8
|
+
export interface Splice { pos: number; removed: string; inserted: string; }
|
|
9
|
+
export interface Transaction {
|
|
10
|
+
id: string; documentAuthority: string; baseRevision: number; revision: number; actor: Actor;
|
|
11
|
+
transport: string; operation: string; requestId: string | null; sourceTransactionId: string | null;
|
|
12
|
+
affectedBlockIds: string[]; parent: string | null; reverts: string | null; reapplies: string | null;
|
|
13
|
+
createdAt: string | null; turnId?: string; sourceTransactionIds?: string[];
|
|
14
|
+
}
|
|
15
|
+
export interface LedgerRecord { transaction: Transaction; splices: Splice[]; beforeHash: string; afterHash: string; metadata?: Record<string, unknown>; authored?: unknown; }
|
|
16
|
+
/** rapier-ledger/2: plain JSON. */
|
|
17
|
+
export interface LedgerEnvelope {
|
|
18
|
+
format: 'rapier-ledger/2';
|
|
19
|
+
documentAuthority: string;
|
|
20
|
+
start: {text: string; revision: number; root: string; sha256: string; metadata: Metadata};
|
|
21
|
+
records: LedgerRecord[];
|
|
22
|
+
head: {revision: number; root: string; sha256: string; metadata: Metadata};
|
|
23
|
+
complete: boolean;
|
|
24
|
+
sha256: string;
|
|
25
|
+
}
|
|
26
|
+
/** Runs of [start, end, actor kind, actor id, createdAt] over the current text. */
|
|
27
|
+
export interface AuthorshipEnvelope { format: 'rapier-authorship/1'; sha256: string; runs: [number, number, ActorKind, string, string | number | null][]; }
|
|
28
|
+
export interface ReadLedger { ledger: LedgerEnvelope; text: string; metadata: Metadata; root: string; revision: number; complete: boolean; }
|
|
29
|
+
export interface LedgerInput { text: string; metadata: Metadata; records: LedgerRecord[]; documentAuthority?: string; revision?: number; root?: string; complete?: boolean; }
|
|
30
|
+
|
|
31
|
+
/** A new ledger with no history. Give every copy that may later merge the same documentAuthority. */
|
|
32
|
+
export function openLedger(text: string, options?: {filename?: string; docKind?: DocKind; documentAuthority?: string}): LedgerEnvelope;
|
|
33
|
+
export function exportLedger(input: LedgerInput): LedgerEnvelope;
|
|
34
|
+
/** Verifies the envelope (and, when given, that it describes this text and metadata); throws when it does not. */
|
|
35
|
+
export function readLedger(input: LedgerEnvelope, expectedText?: string, expectedMetadata?: Metadata): ReadLedger;
|
|
36
|
+
/** Records one replacement of the whole text as an act by `actor`. */
|
|
37
|
+
export function replaceLedgerText(input: LedgerEnvelope, text: string, options?: {actor?: Actor; operation?: string; at?: string}): LedgerEnvelope;
|
|
38
|
+
export function ledgerWithStart(input: LedgerEnvelope, text: string): LedgerEnvelope;
|
|
39
|
+
export function historyEnvelope(input: LedgerEnvelope, segmentIdentity?: unknown[], expectedText?: string, expectedMetadata?: Metadata): Record<string, unknown>;
|
|
40
|
+
/** The actor id an agent writes under, for a door and an agent name. */
|
|
41
|
+
export function agentActorId(door: string, agent: string): string;
|
|
42
|
+
export function textRoot(text: string): string;
|
|
43
|
+
export function rootAfter(root: string, splice: Splice): string;
|
|
44
|
+
export function sha256State(): {update(text: string): unknown; digest(): string};
|
|
45
|
+
|
|
46
|
+
export function authorship(input: LedgerEnvelope): AuthorshipEnvelope;
|
|
47
|
+
export function readAuthorship(input: AuthorshipEnvelope, text: string): AuthorshipEnvelope;
|
|
48
|
+
export function ledgerFromAuthorship(input: AuthorshipEnvelope, text: string, documentAuthority: string, metadata: Metadata): LedgerEnvelope;
|
|
49
|
+
|
|
50
|
+
export interface MergeConflict { kind: string; code?: string; actId?: string; [key: string]: unknown; }
|
|
51
|
+
export interface MergeResult {
|
|
52
|
+
text: string; metadata: Metadata; ledger: LedgerEnvelope;
|
|
53
|
+
/** False when a person must review: conflicts, or Will keep regions that differ. */
|
|
54
|
+
clean: boolean;
|
|
55
|
+
conflicts: MergeConflict[];
|
|
56
|
+
review: {required: boolean; base: string; proposed: string};
|
|
57
|
+
}
|
|
58
|
+
/** Merges two copies of one document (same documentAuthority) without a server. */
|
|
59
|
+
export function merge(first: LedgerEnvelope, second: LedgerEnvelope): MergeResult;
|
|
60
|
+
export function transportInterval(start: number, end: number, splices: Splice[]): {start: number; end: number} | null;
|
|
61
|
+
export function transportTouchedInterval(start: number, end: number, splices: Splice[]): {start: number; end: number};
|
|
62
|
+
|
|
63
|
+
export interface HistoryInput { source: string; metadata: Metadata; records: LedgerRecord[]; revision: number; earliestRevision: number; complete: boolean; }
|
|
64
|
+
export interface HistoryFailure { ok?: false; code: string; [key: string]: unknown; }
|
|
65
|
+
/** Reverses chosen acts while later work stays. */
|
|
66
|
+
export function selectiveUndo(input: HistoryInput, actIds: string[], options?: {actor?: Actor; splitSplice?: (...args: unknown[]) => unknown}): Record<string, unknown> | HistoryFailure;
|
|
67
|
+
export function replayHistory(input: HistoryInput): unknown;
|
|
68
|
+
export function sourceBefore(input: HistoryInput, actId: string): unknown;
|
|
69
|
+
export function historyProjection(input: HistoryInput, range?: {from?: number; to?: number; actId?: string}): unknown;
|
|
70
|
+
export function historyProjections(input: HistoryInput, ranges: unknown[]): unknown;
|
|
71
|
+
export function historyPlaces(input: HistoryInput, blocks: unknown[]): unknown;
|
|
72
|
+
export function groupHistoryActs(acts: unknown[]): unknown;
|
|
73
|
+
export function pictureHistorySplices(input: unknown, target: unknown, redo: boolean): Splice[] | null;
|
|
74
|
+
export function transposeSource(base: string, splices: Splice[], client: unknown, log: unknown): unknown;
|
|
75
|
+
export function authoredBasis(...args: unknown[]): unknown;
|
|
76
|
+
export function authoredPlacement(...args: unknown[]): unknown;
|
|
77
|
+
export function authoredHistory(base: string, records: LedgerRecord[], selected?: unknown): unknown;
|
|
78
|
+
export function transposeAuthored(base: string, records: LedgerRecord[], incoming: unknown, submitted: unknown): unknown;
|
|
79
|
+
|
|
80
|
+
export {writeDocument, readDocument, writeParts, readParts} from './ledger/carried.mjs';
|
|
81
|
+
|
|
82
|
+
export interface TextConflict { kind: 'text'; id: string; start: number; end: number; block: string; variants: string[]; }
|
|
83
|
+
export interface TextMerge { text: string; clean: boolean; conflicts: (TextConflict | {kind: string; advisory?: boolean})[]; }
|
|
84
|
+
/** Exact three-way text merge; a region both sides changed becomes a marked choice in the text. */
|
|
85
|
+
export function mergeText(base: string | null, ours: string, theirs: string, options?: Record<string, unknown>): TextMerge;
|
|
86
|
+
export function inspectTextConflicts(text: string, options?: {nested?: boolean}): TextConflict[];
|
|
87
|
+
export function resolveTextConflict(text: string, conflict: TextConflict | string, device: unknown): string;
|
|
88
|
+
export function mapTextConflictVariants(text: string, mapper: (variant: string) => string): string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// The public edit ledger (rapier-markdown-kit/rapier-ledger): the history, its carried parts and three-way text merge.
|
|
3
|
+
// The editor binds kit/rapier-ledger.mjs, which also carries its private record helpers.
|
|
4
|
+
import {exportLedger} from './ledger/format.mjs';
|
|
5
|
+
export {exportLedger, replaceLedgerText, readLedger, ledgerWithStart, textRoot, rootAfter, historyEnvelope, agentActorId} from './ledger/format.mjs';
|
|
6
|
+
export {authorship, readAuthorship, ledgerFromAuthorship} from './ledger/authorship.mjs';
|
|
7
|
+
export {merge, transportInterval, transportTouchedInterval} from './ledger/merge.mjs';
|
|
8
|
+
export {replayHistory, sourceBefore, historyProjection, historyProjections, historyPlaces, selectiveUndo, groupHistoryActs, pictureHistorySplices} from './ledger/history.mjs';
|
|
9
|
+
export {sha256State} from './ledger/hash.mjs';
|
|
10
|
+
export {transposeSource, authoredBasis, authoredPlacement, authoredHistory, transposeAuthored} from './ledger/transport.mjs';
|
|
11
|
+
export {writeDocument, readDocument, writeParts, readParts} from './ledger/carried.mjs';
|
|
12
|
+
export {mergeText, resolveTextConflict, inspectTextConflicts, mapTextConflictVariants} from './ledger/text-merge.mjs';
|
|
13
|
+
|
|
14
|
+
// A new ledger for one text, with no history yet. documentAuthority names the document across its copies:
|
|
15
|
+
// pass the same one to every copy that may later merge.
|
|
16
|
+
export function openLedger(text, {filename = 'document.md', docKind = 'markdown', documentAuthority = globalThis.crypto.randomUUID()} = {}) {
|
|
17
|
+
return exportLedger({text, metadata: {filename, docKind}, records: [], documentAuthority, complete: true});
|
|
18
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// md.use(rapierMarkdown): Self-contained Markdown in markdown-it, with the reference style's markup.
|
|
3
|
+
interface MarkdownItLike { use(plugin: (md: any, ...params: any[]) => unknown, ...params: any[]): any; renderer: {rules: Record<string, unknown>}; core: {ruler: {after(after: string, name: string, rule: (state: any) => unknown): void}}; }
|
|
4
|
+
export function rapierMarkdown<T extends MarkdownItLike>(md: T): T;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// rapier-markdown-kit/markdown-it: md.use(rapierMarkdown) renders Self-contained Markdown with the reference style's markup
|
|
3
|
+
// (wrap the output in <main class="md-render"> with rapier-markdown-kit/style.css). Create markdown-it with {html: true}.
|
|
4
|
+
import {parseLayout, imageStyle} from './layout.mjs';
|
|
5
|
+
import {typography, isPageBreakBlock} from './marks.mjs';
|
|
6
|
+
import {installMarkdownImages} from './assets.mjs';
|
|
7
|
+
|
|
8
|
+
const PAGE_BREAK = '<div class="rapier-page-break" data-md-break="page" role="separator" aria-label="Page break"></div>\n';
|
|
9
|
+
|
|
10
|
+
export function rapierMarkdown(md) {
|
|
11
|
+
if (md.__rapierMarkdownInstalled) return md;
|
|
12
|
+
md.__rapierMarkdownInstalled = true;
|
|
13
|
+
typography.install(installMarkdownImages(md));
|
|
14
|
+
md.renderer.rules.table_open = (tokens, index, options, env, renderer) =>
|
|
15
|
+
'<div class="table-scroll-wrap">\n' + renderer.renderToken(tokens, index, options);
|
|
16
|
+
md.renderer.rules.table_close = (tokens, index, options, env, renderer) =>
|
|
17
|
+
renderer.renderToken(tokens, index, options) + '</div>\n';
|
|
18
|
+
// A paragraph's or heading's closing layout comment: its alignment, and a lone picture's width.
|
|
19
|
+
md.core.ruler.after('inline', 'rapier-layout', state => {
|
|
20
|
+
for (let i = 1; i < state.tokens.length; i++) {
|
|
21
|
+
const inline = state.tokens[i], owner = state.tokens[i - 1];
|
|
22
|
+
if (inline.type !== 'inline' || !['paragraph_open', 'heading_open'].includes(owner.type)) continue;
|
|
23
|
+
const children = inline.children, marker = children.at(-1);
|
|
24
|
+
const layout = marker?.type === 'html_inline' ? parseLayout(marker.content) : null;
|
|
25
|
+
if (!layout) continue;
|
|
26
|
+
children.pop();
|
|
27
|
+
if (layout.align) owner.attrSet('data-md-align', layout.align);
|
|
28
|
+
const visible = children.filter(token => token.type !== 'text' || token.content.trim());
|
|
29
|
+
if (visible.length === 1 && visible[0].type === 'image' && layout.width) {
|
|
30
|
+
visible[0].attrSet('style', imageStyle(layout));
|
|
31
|
+
visible[0].attrSet('data-md-image-width', String(layout.width));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
const htmlBlock = md.renderer.rules.html_block || ((tokens, index) => tokens[index].content);
|
|
36
|
+
md.renderer.rules.html_block = (tokens, index, options, env, renderer) =>
|
|
37
|
+
isPageBreakBlock(tokens[index].content) ? PAGE_BREAK : htmlBlock(tokens, index, options, env, renderer);
|
|
38
|
+
return md;
|
|
39
|
+
}
|
package/dist/kit/pdf-glyphs.mjs
CHANGED
|
@@ -132,9 +132,10 @@ export async function collectPdfGlyphs(main, {width, check, faces = []}) {
|
|
|
132
132
|
if (++visited % 128 === 0) {await new Promise(resolve => setTimeout(resolve, 0)); check();}
|
|
133
133
|
// Leaving the existing element in place preserves selectors, counters, line breaking and inline marks.
|
|
134
134
|
// Keep preformatted blocks, their inline struts and syntax paint together.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
// A paragraph's own words beside a mark, a link or code are drawn too: each text node is placed and hidden on its own.
|
|
136
|
+
if (element.namespaceURI !== 'http://www.w3.org/1999/xhtml' || element.closest('svg,pre,.rapier-will-carrier')) continue;
|
|
137
|
+
const textNodes = Array.from(element.childNodes).filter(node => node.nodeType === 3 && node.data.trim());
|
|
138
|
+
if (!textNodes.length || !safe(element)) continue;
|
|
138
139
|
const language = (element.closest('[lang]')?.getAttribute('lang') || document.documentElement.lang || '').toLowerCase();
|
|
139
140
|
if (language && !/^en(?:-|$)/.test(language)) continue;
|
|
140
141
|
const style = styleOf(element), family = familyName(style.fontFamily.split(',')[0]), weight = Number(style.fontWeight), size = Number.parseFloat(style.fontSize);
|
|
@@ -148,49 +149,51 @@ export async function collectPdfGlyphs(main, {width, check, faces = []}) {
|
|
|
148
149
|
if (generated || altered || !Array.from(document.fonts).some(face => familyName(face.family) === family && face.status === 'loaded')) continue;
|
|
149
150
|
const asset = faces.find(face => face.family === family && face.weight === weight);
|
|
150
151
|
if (!asset) continue;
|
|
151
|
-
const font = readFont(asset, fontCache), preserve = /pre|break-spaces/.test(style.whiteSpace)
|
|
152
|
-
const shaped = shape(node.data, font, preserve, style.fontVariantLigatures === 'normal' && !(Number.parseFloat(style.letterSpacing) || 0));
|
|
153
|
-
if (!shaped) continue;
|
|
152
|
+
const font = readFont(asset, fontCache), preserve = /pre|break-spaces/.test(style.whiteSpace);
|
|
154
153
|
context.font = [style.fontStyle, style.fontWeight, style.fontSize, style.fontFamily].join(' ');
|
|
155
154
|
const metrics = context.measureText('Hg'), ascent = metrics.fontBoundingBoxAscent;
|
|
156
155
|
if (!Number.isFinite(ascent)) continue;
|
|
157
156
|
const rgba = color(style.webkitTextFillColor || style.color), range = document.createRange();
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
157
|
+
for (const node of textNodes) {
|
|
158
|
+
const shaped = shape(node.data, font, preserve, style.fontVariantLigatures === 'normal' && !(Number.parseFloat(style.letterSpacing) || 0));
|
|
159
|
+
if (!shaped) continue;
|
|
160
|
+
let valid = true;
|
|
161
|
+
const placed = [];
|
|
162
|
+
for (const item of shaped) {
|
|
163
|
+
range.setStart(node, item.start); range.setEnd(node, item.end);
|
|
164
|
+
const ink = Array.from(range.getClientRects()).filter(rect => rect.height > 0);
|
|
165
|
+
// A break after a soft hyphen can leave the next letter with a phantom box on the
|
|
166
|
+
// hyphen's line. Keep the line that actually holds the ink.
|
|
167
|
+
const lines = [];
|
|
168
|
+
for (const rect of ink) {
|
|
169
|
+
const line = lines.find(row => Math.abs(row[0].top - rect.top) <= .25);
|
|
170
|
+
if (line) line.push(rect); else lines.push([rect]);
|
|
171
|
+
}
|
|
172
|
+
lines.sort((a, b) => b.reduce((sum, rect) => sum + rect.width, 0) - a.reduce((sum, rect) => sum + rect.width, 0));
|
|
173
|
+
const previous = shaped[shaped.indexOf(item) - 1];
|
|
174
|
+
if (lines.length > 1 && !item.soft && !previous?.soft) {valid = false; break;}
|
|
175
|
+
const rects = lines[0] || [];
|
|
176
|
+
const first = (item.soft ? rects.find(rect => rect.width > 0) : null) || rects[0];
|
|
177
|
+
// An unbroken soft hyphen has no ink. Leave it out of the vector run.
|
|
178
|
+
if (item.soft && !(first?.width > 0)) continue;
|
|
179
|
+
if (!first) {valid = false; break;}
|
|
180
|
+
const page = Math.max(0, Math.floor((first.left - origin.left + .01) / width));
|
|
181
|
+
Object.assign(item, {font, size, rgba, x: first.left - origin.left - page * width, baseline: first.top - origin.top + ascent});
|
|
182
|
+
placed.push(item);
|
|
169
183
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (!valid || !placed.length) continue;
|
|
183
|
-
nodes.set(node, placed);
|
|
184
|
-
if (element.localName === 'li') {
|
|
185
|
-
// A list item's marker inherits its fill and is not vectorized: hide only the words.
|
|
186
|
-
const wrap = document.createElement('span');
|
|
187
|
-
node.replaceWith(wrap); wrap.append(node);
|
|
188
|
-
wrap.style.setProperty('-webkit-text-fill-color', 'transparent', 'important');
|
|
189
|
-
hidden.push({wrap});
|
|
190
|
-
continue;
|
|
184
|
+
if (!valid || !placed.length) continue;
|
|
185
|
+
nodes.set(node, placed);
|
|
186
|
+
if (element.localName === 'li' || element.children.length || textNodes.length > 1) {
|
|
187
|
+
// A list item's marker and a paragraph's marks are not this node's words: hide only the words.
|
|
188
|
+
const wrap = document.createElement('span');
|
|
189
|
+
node.replaceWith(wrap); wrap.append(node);
|
|
190
|
+
wrap.style.setProperty('-webkit-text-fill-color', 'transparent', 'important');
|
|
191
|
+
hidden.push({wrap});
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
hidden.push({node: element, value: element.style.getPropertyValue('-webkit-text-fill-color'), priority: element.style.getPropertyPriority('-webkit-text-fill-color')});
|
|
195
|
+
element.style.setProperty('-webkit-text-fill-color', 'transparent', 'important');
|
|
191
196
|
}
|
|
192
|
-
hidden.push({node: element, value: element.style.getPropertyValue('-webkit-text-fill-color'), priority: element.style.getPropertyPriority('-webkit-text-fill-color')});
|
|
193
|
-
element.style.setProperty('-webkit-text-fill-color', 'transparent', 'important');
|
|
194
197
|
}
|
|
195
198
|
check(); return {nodes, restore};
|
|
196
199
|
} catch (error) {restore(); throw error;}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Attach the exact source to a PDF Rapier wrote, without rewriting its pages (rapier-markdown-kit/pdf).
|
|
3
|
+
export function readPdfPageCount(input: Uint8Array | ArrayBuffer): number;
|
|
4
|
+
/** The PDF with `source` attached as an associated file; throws `pdf_source_invalid` for a PDF it cannot extend. */
|
|
5
|
+
export function attachPdfSource(input: Uint8Array | ArrayBuffer, options: {source: string; filename: string; maxBytes?: number; check?: () => void}): Promise<Uint8Array>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Add an associated source file to Rapier's fresh PDF without rewriting its pages.
|
|
3
|
+
function pdfStructure(input) {
|
|
4
|
+
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
|
|
5
|
+
const fail = () => { throw Object.assign(new Error('The PDF source could not be attached.'), {code: 'pdf_source_invalid'}); };
|
|
6
|
+
let text = '';
|
|
7
|
+
for (let at = 0; at < bytes.length; at += 32768) text += String.fromCharCode(...bytes.subarray(at, at + 32768));
|
|
8
|
+
if (!text.startsWith('%PDF-')) fail();
|
|
9
|
+
const end = /startxref\s+(\d+)\s+%%EOF\s*$/.exec(text), xref = Number(end?.[1]);
|
|
10
|
+
if (!end || !Number.isSafeInteger(xref) || text.slice(xref, xref + 4) !== 'xref') fail();
|
|
11
|
+
const offsets = new Map();
|
|
12
|
+
let at = xref + 4;
|
|
13
|
+
const space = () => { while (at < text.length && /[\s\0]/.test(text[at])) at++; };
|
|
14
|
+
for (;;) {
|
|
15
|
+
space(); if (text.startsWith('trailer', at)) { at += 7; break; }
|
|
16
|
+
const section = /^(\d+)\s+(\d+)\s+/.exec(text.slice(at)); if (!section) fail();
|
|
17
|
+
at += section[0].length;
|
|
18
|
+
const from = Number(section[1]), count = Number(section[2]);
|
|
19
|
+
if (!Number.isSafeInteger(from + count) || count > bytes.length / 18) fail();
|
|
20
|
+
for (let i = 0; i < count; i++) {
|
|
21
|
+
const entry = /^(\d{10})\s+(\d{5})\s+([nf])\s+/.exec(text.slice(at)); if (!entry) fail();
|
|
22
|
+
at += entry[0].length;
|
|
23
|
+
if (entry[3] === 'n') offsets.set(from + i, {at: Number(entry[1]), generation: Number(entry[2])});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const skip = pos => { for (;;) { while (pos < text.length && /[\s\0]/.test(text[pos])) pos++; if (text[pos] !== '%') return pos; while (pos < text.length && !/[\r\n]/.test(text[pos])) pos++; } };
|
|
27
|
+
const tokenEnd = pos => { while (pos < text.length && !/[\s\0()[\]<>/%]/.test(text[pos])) pos++; return pos; };
|
|
28
|
+
const valueEnd = (pos, depth = 0) => {
|
|
29
|
+
if (depth > 32) fail(); pos = skip(pos);
|
|
30
|
+
if (text.startsWith('<<', pos)) {
|
|
31
|
+
pos += 2;
|
|
32
|
+
for (;;) { pos = skip(pos); if (text.startsWith('>>', pos)) return pos + 2; if (text[pos] !== '/') fail(); pos = valueEnd(tokenEnd(pos + 1), depth + 1); }
|
|
33
|
+
}
|
|
34
|
+
if (text[pos] === '[') { pos++; for (;;) { pos = skip(pos); if (text[pos] === ']') return pos + 1; pos = valueEnd(pos, depth + 1); } }
|
|
35
|
+
if (text[pos] === '(') {
|
|
36
|
+
let nesting = 1; pos++;
|
|
37
|
+
while (pos < text.length && nesting) { const c = text[pos++]; if (c === '\\') pos++; else if (c === '(') nesting++; else if (c === ')') nesting--; }
|
|
38
|
+
if (nesting) fail(); return pos;
|
|
39
|
+
}
|
|
40
|
+
if (text[pos] === '<') { const close = text.indexOf('>', pos + 1); if (close < 0) fail(); return close + 1; }
|
|
41
|
+
if (text[pos] === '/') return tokenEnd(pos + 1);
|
|
42
|
+
const first = tokenEnd(pos); if (first === pos) fail();
|
|
43
|
+
const reference = /^\d+\s+\d+\s+R\b/.exec(text.slice(pos)); return reference ? pos + reference[0].length : first;
|
|
44
|
+
};
|
|
45
|
+
const dictionary = pos => {
|
|
46
|
+
pos = skip(pos); if (!text.startsWith('<<', pos)) fail();
|
|
47
|
+
const start = pos, fields = new Map(); pos += 2;
|
|
48
|
+
for (;;) {
|
|
49
|
+
pos = skip(pos); if (text.startsWith('>>', pos)) return {start, end: pos + 2, fields};
|
|
50
|
+
if (text[pos] !== '/') fail();
|
|
51
|
+
const keyStart = pos, keyEnd = tokenEnd(pos + 1), name = text.slice(pos + 1, keyEnd);
|
|
52
|
+
if (fields.has(name)) fail();
|
|
53
|
+
const from = skip(keyEnd), end = valueEnd(from);
|
|
54
|
+
fields.set(name, {start: keyStart, from, end, value: text.slice(from, end)}); pos = end;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const trailer = dictionary(at), rootRef = /^(\d+)\s+(\d+)\s+R$/.exec(trailer.fields.get('Root')?.value || '');
|
|
58
|
+
const size = Number(trailer.fields.get('Size')?.value), rootId = Number(rootRef?.[1]), generation = Number(rootRef?.[2]);
|
|
59
|
+
let highest = 0; for (const id of offsets.keys()) highest = Math.max(highest, id);
|
|
60
|
+
if (!rootRef || !Number.isSafeInteger(size) || size <= highest || trailer.fields.has('Encrypt') || trailer.fields.has('Prev')) fail();
|
|
61
|
+
const readDictionary = (id, gen) => {
|
|
62
|
+
const place = offsets.get(id); if (!place || place.generation !== gen) fail();
|
|
63
|
+
const header = new RegExp('^' + id + '\\s+' + gen + '\\s+obj\\b').exec(text.slice(place.at)); if (!header) fail();
|
|
64
|
+
return dictionary(place.at + header[0].length);
|
|
65
|
+
};
|
|
66
|
+
const root = readDictionary(rootId, generation);
|
|
67
|
+
return {bytes, text, offsets, root, rootId, generation, trailer, size, xref, readDictionary, dictionary, fail};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function readPdfPageCount(input) {
|
|
71
|
+
const {root, readDictionary, fail} = pdfStructure(input);
|
|
72
|
+
const reference = /^(\d+)\s+(\d+)\s+R$/.exec(root.fields.get('Pages')?.value || '');
|
|
73
|
+
if (!reference) fail();
|
|
74
|
+
const pages = readDictionary(Number(reference[1]), Number(reference[2])), count = Number(pages.fields.get('Count')?.value);
|
|
75
|
+
if (pages.fields.get('Type')?.value !== '/Pages' || !Number.isSafeInteger(count) || count < 1) fail();
|
|
76
|
+
return count;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function attachPdfSource(input, {source, filename, maxBytes = Infinity, check = () => {}}) {
|
|
80
|
+
const {bytes, text, offsets, root, rootId, generation, trailer, size, readDictionary, dictionary, fail} = pdfStructure(input);
|
|
81
|
+
if (typeof source !== 'string' || typeof filename !== 'string' || !filename) fail();
|
|
82
|
+
const ascii = value => Uint8Array.from(value, character => character.charCodeAt(0));
|
|
83
|
+
const encode = new TextEncoder(), hex = value => Array.from({length: value.length}, (_, i) => value.charCodeAt(i).toString(16).padStart(4, '0')).join('');
|
|
84
|
+
if (root.fields.get('Type')?.value !== '/Catalog' || root.fields.has('AF')) fail();
|
|
85
|
+
let names = '<<', remove = root.fields.get('Names');
|
|
86
|
+
if (remove) {
|
|
87
|
+
const ref = /^(\d+)\s+(\d+)\s+R$/.exec(remove.value);
|
|
88
|
+
const held = ref ? readDictionary(Number(ref[1]), Number(ref[2])) : dictionary(remove.from);
|
|
89
|
+
if (held.fields.has('EmbeddedFiles')) fail();
|
|
90
|
+
names = text.slice(held.start, held.end - 2);
|
|
91
|
+
}
|
|
92
|
+
const original = remove ? text.slice(root.start, remove.start) + text.slice(remove.end, root.end - 2) : text.slice(root.start, root.end - 2);
|
|
93
|
+
const payload = encode.encode(source); check();
|
|
94
|
+
const compressed = new Uint8Array(await new Response(new Blob([payload]).stream().pipeThrough(new CompressionStream('deflate'))).arrayBuffer()); check();
|
|
95
|
+
const fileId = size, streamId = size + 1, infoId = size + 2, totalSize = size + 3;
|
|
96
|
+
let infoBody = '<< /Producer (Rapier) >>';
|
|
97
|
+
const existingInfo = trailer.fields.get('Info');
|
|
98
|
+
if (existingInfo) {
|
|
99
|
+
const ref = /^(\d+)\s+(\d+)\s+R$/.exec(existingInfo.value);
|
|
100
|
+
const held = ref ? readDictionary(Number(ref[1]), Number(ref[2])) : dictionary(existingInfo.from);
|
|
101
|
+
const producer = held.fields.get('Producer');
|
|
102
|
+
const kept = producer ? text.slice(held.start, producer.start) + text.slice(producer.end, held.end - 2) : text.slice(held.start, held.end - 2);
|
|
103
|
+
infoBody = kept + ' /Producer (Rapier) >>';
|
|
104
|
+
}
|
|
105
|
+
const escaped = '(' + filename.replace(/[^\x20-\x7e]/g, '_').replace(/[\\()]/g, '\\$&') + ')';
|
|
106
|
+
const parts = [bytes, ascii('\n')], merged = new Map(offsets);
|
|
107
|
+
let length = bytes.length + 1;
|
|
108
|
+
const append = (id, gen, chunks) => { merged.set(id, {at: length, generation: gen}); const body = [ascii(id + ' ' + gen + ' obj\n'), ...chunks, ascii('\nendobj\n')]; for (const chunk of body) {parts.push(chunk); length += chunk.length;} };
|
|
109
|
+
append(rootId, generation, [ascii(original + ' /Names ' + names + ' /EmbeddedFiles << /Names [<feff' + hex(filename) + '> ' + fileId + ' 0 R] >> >> /AF [' + fileId + ' 0 R] >>')]);
|
|
110
|
+
append(fileId, 0, [ascii('<< /Type /Filespec /F ' + escaped + ' /UF <feff' + hex(filename) + '> /AFRelationship /Source /EF << /F ' + streamId + ' 0 R /UF ' + streamId + ' 0 R >> >>')]);
|
|
111
|
+
append(streamId, 0, [ascii('<< /Type /EmbeddedFile /Subtype /text#2Fmarkdown /Params << /Size ' + payload.length + ' >> /Filter /FlateDecode /Length ' + compressed.length + ' >>\nstream\n'), compressed, ascii('\nendstream')]);
|
|
112
|
+
append(infoId, 0, [ascii(infoBody)]);
|
|
113
|
+
let table = 'xref\n0 ' + totalSize + '\n0000000000 65535 f \n';
|
|
114
|
+
for (let id = 1; id < totalSize; id++) {
|
|
115
|
+
const place = merged.get(id);
|
|
116
|
+
table += place ? String(place.at).padStart(10, '0') + ' ' + String(place.generation).padStart(5, '0') + ' n \n' : '0000000000 00000 f \n';
|
|
117
|
+
}
|
|
118
|
+
const identity = trailer.fields.get('ID');
|
|
119
|
+
table += 'trailer\n<< /Size ' + totalSize + ' /Root ' + rootId + ' ' + generation + ' R /Info ' + infoId + ' 0 R' + (identity ? ' /ID ' + identity.value : '') + ' >>\nstartxref\n' + length + '\n%%EOF\n';
|
|
120
|
+
parts.push(ascii(table)); length += table.length;
|
|
121
|
+
if (length > maxBytes) throw Object.assign(new Error('The exported file exceeds the file limit.'), {code: 'export_too_large'});
|
|
122
|
+
const result = new Uint8Array(length); let at = 0;
|
|
123
|
+
for (const chunk of parts) { result.set(chunk, at); at += chunk.length; }
|
|
124
|
+
check(); return result;
|
|
125
|
+
}
|
|
@@ -410,7 +410,7 @@ function initMarkdownIt() {
|
|
|
410
410
|
}
|
|
411
411
|
|
|
412
412
|
function _rapierRenderSemanticRoot(canonical, metadata, options = {}) {
|
|
413
|
-
const root = document.createElement('div');
|
|
413
|
+
const root = document.createElement('template').content.ownerDocument.createElement('div');
|
|
414
414
|
root.setAttribute('data-rapier-semantic-root', 'true');
|
|
415
415
|
|
|
416
416
|
if (metadata.docKind !== 'markdown') {
|
|
@@ -469,7 +469,8 @@ async function _rapierRenderSemanticRootAsync(canonical, metadata, options = {})
|
|
|
469
469
|
let {tokens, env} = parsed, labels = env.footnotes?.list?.map((item, index) => String(item?.label || ('inline-' + (index + 1))));
|
|
470
470
|
env = {...env, rapierImagePresentation: options.imagePresentation === true};
|
|
471
471
|
await pause(work, .3);
|
|
472
|
-
const
|
|
472
|
+
const inert = document.createElement('template').content.ownerDocument;
|
|
473
|
+
const root = inert.createElement('div'), part = inert.createElement('div'), boundary = htmlBoundary();
|
|
473
474
|
root.setAttribute('data-rapier-semantic-root', 'true');
|
|
474
475
|
let html = '', began = performance.now(), complete = false;
|
|
475
476
|
const append = () => {
|
|
@@ -5,6 +5,8 @@ import {fontRequests, waitForFonts} from './export-fonts.mjs';
|
|
|
5
5
|
import {apply as applyTypography} from '../layout/typography.mjs';
|
|
6
6
|
import {documentLanguage} from '../spec/document-settings.mjs';
|
|
7
7
|
import {applyPrintCapitals} from './print-capitals.mjs';
|
|
8
|
+
import {stripOneTerminator as _rapierWillStripOneTerminator} from '../agent/will.mjs';
|
|
9
|
+
import {quotedWillSpan} from '../spec/md-marks.mjs';
|
|
8
10
|
const RAPIER_WILL_SENTINEL_MARK = '';
|
|
9
11
|
// SPDX-License-Identifier: MIT
|
|
10
12
|
// The document's renderer. DOM, codecs and host state are explicit inputs.
|
|
@@ -27,12 +29,11 @@ function _rapierWillSentinelPlan(canonical, kind) {
|
|
|
27
29
|
return { canonical: out + source.slice(cursor), markers, sentinel };
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
const _rapierWillStripOneTerminator = globalThis.RapierAgentWill.stripOneTerminator;
|
|
31
32
|
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
function _rapierPrintWillAdmit(canonical, plan) {
|
|
35
|
-
const quoted =
|
|
36
|
+
const quoted = quotedWillSpan(canonical, _rapierWillParse(canonical).blocks.map(block => [block.start, block.end]));
|
|
36
37
|
if (quoted && quoted.where === 'frontmatter') throw _rapierPrintWillLost('a marker stands in the front matter, which is not on the printed page', 'Take it out of the front matter');
|
|
37
38
|
if (quoted) throw _rapierPrintWillLost('a marker stands inside other content, where a reader of the PDF\'s text could not tell it from a carried marker', 'Take the quoted marker out');
|
|
38
39
|
for (const marker of plan.markers) {
|
|
@@ -91,8 +91,10 @@ function prepareMathSource(input, DOMParser) {
|
|
|
91
91
|
// An inert parser and a string sanitizer use the same profile as DOMPurify. Neither port may
|
|
92
92
|
// execute document scripts or fetch resources. HTML5 parsing precedes filtering so foreign
|
|
93
93
|
// content, entities and malformed table markup cross the same boundary as a browser's parser.
|
|
94
|
-
function createInertHtmlSanitizer({parseFragment, serialize, sanitizeHtml, safeRasterDataUrl}) {
|
|
95
|
-
|
|
94
|
+
function createInertHtmlSanitizer({parseFragment, serialize, sanitizeHtml, safeRasterDataUrl, chromeOwnsId = id => /^rapier-(?!anchor-)/.test(id)}) {
|
|
95
|
+
// chromeOwnsId defaults to _rapierChromeOwnsId's prefix rule: every page Rapier writes carries rapier- elements
|
|
96
|
+
// (rapier-document, rapier-ledger). The factories are serialized into written pages, so neither reads a module constant.
|
|
97
|
+
if (![parseFragment, serialize, sanitizeHtml, safeRasterDataUrl, chromeOwnsId].every(port => typeof port === 'function'))
|
|
96
98
|
throw new TypeError('The inert HTML sanitizer requires every parser and filtering port');
|
|
97
99
|
const uri = /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i;
|
|
98
100
|
const uriSafe = new Set(['alt', 'class', 'for', 'id', 'label', 'name', 'pattern', 'placeholder', 'role', 'summary', 'title', 'value', 'style', 'xmlns']);
|
|
@@ -317,7 +319,10 @@ function _rapierInstallSanitizeHooks() {
|
|
|
317
319
|
node.removeAttribute('data-rapier-word-marker');
|
|
318
320
|
const style = node.getAttribute('style');
|
|
319
321
|
if (!style || !/mso-list/i.test(style)) return;
|
|
320
|
-
|
|
322
|
+
// Word's list reading is the editor's (AGPL) grammar; a host without it keeps the paste's paragraphs as they are.
|
|
323
|
+
const spec = globalThis.RapierMarkdownSpec;
|
|
324
|
+
if (!spec) return;
|
|
325
|
+
const {wordListLevel, wordListIsMarker} = spec;
|
|
321
326
|
const level = /^(?:P|LI|H[1-6])$/.test(node.nodeName) ? wordListLevel(style) : 0;
|
|
322
327
|
if (level) node.setAttribute('data-rapier-word-level', String(level));
|
|
323
328
|
else if (node.nodeName === 'SPAN' && wordListIsMarker(style)) node.setAttribute('data-rapier-word-marker', '1');
|
|
@@ -446,6 +451,17 @@ function _rapierChromeOwnsId(id) {
|
|
|
446
451
|
return _rapierSanitizeRuntime.chromeIds.has(String(id)) || /^rapier-(?!anchor-)/.test(id);
|
|
447
452
|
}
|
|
448
453
|
|
|
454
|
+
// A link whose words or address carry a bidi control, or whose host is spelled in punycode, can show a destination it does
|
|
455
|
+
// not have. Opening one names its real address first; the answer is that address, or '' when the link opens at once.
|
|
456
|
+
const RAPIER_BIDI_CONTROL = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/;
|
|
457
|
+
function _rapierLinkDestinationToConfirm(href, text = '') {
|
|
458
|
+
let url, address = String(href || '');
|
|
459
|
+
try { url = new URL(address); } catch (_) { return ''; }
|
|
460
|
+
try { address = decodeURIComponent(address); } catch (_) {}
|
|
461
|
+
return RAPIER_BIDI_CONTROL.test(String(text)) || RAPIER_BIDI_CONTROL.test(address) ||
|
|
462
|
+
url.hostname.split('.').some(label => /^xn--/i.test(label)) ? url.href : '';
|
|
463
|
+
}
|
|
464
|
+
|
|
449
465
|
function _rapierSafeRasterDataUrl(value) {
|
|
450
466
|
const match = RAPIER_RASTER_DATA_URL_RE.exec(value);
|
|
451
467
|
if (!match) return false;
|
|
@@ -528,7 +544,7 @@ function _rapierDropRemoteRules(rules, owner) {
|
|
|
528
544
|
}
|
|
529
545
|
|
|
530
546
|
function _rapierVerifyRasterBytes(bytes, mime) {
|
|
531
|
-
if (mime === 'image/jxl') return globalThis.RapierImageAssets
|
|
547
|
+
if (mime === 'image/jxl') return globalThis.RapierImageAssets?.isJxl(bytes) === true; // without the editor's codec a JPEG XL picture is refused
|
|
532
548
|
|
|
533
549
|
// declaration, doctype or generator/license comment before <svg itself, of whatever length its
|
|
534
550
|
|
|
@@ -542,6 +558,6 @@ function _rapierVerifyRasterBytes(bytes, mime) {
|
|
|
542
558
|
|
|
543
559
|
return {_rapierVerifyRasterBytes, _rapierInstallSanitizeHooks, sanitizeRapierHtml,
|
|
544
560
|
sanitizeRapierFragment: typeof DOMPurify?.sanitize === 'function' ? sanitizeRapierFragment : undefined,
|
|
545
|
-
diagramLabelStyle, escapeRapierHtmlText, _rapierChromeOwnsId, _rapierSafeRasterDataUrl, _rapierCssPresentationIsRemote, _rapierRemoteSubresourceOrigin, _rapierStyleWithoutRemoteUrls, _rapierStylesheetWithoutRemoteUrls, _rapierDropRemoteRules};
|
|
561
|
+
diagramLabelStyle, escapeRapierHtmlText, _rapierChromeOwnsId, _rapierLinkDestinationToConfirm, _rapierSafeRasterDataUrl, _rapierCssPresentationIsRemote, _rapierRemoteSubresourceOrigin, _rapierStyleWithoutRemoteUrls, _rapierStylesheetWithoutRemoteUrls, _rapierDropRemoteRules};
|
|
546
562
|
}
|
|
547
563
|
export {RAPIER_SANITIZE_FORBID_TAGS as forbidTags, RAPIER_SANITIZE_FORBID_ATTR as forbidAttributes, createRenderSanitizer, createInertHtmlSanitizer, RAPIER_SANITIZE_OPTIONS as options, prepareMathSource};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Will/1: permissions a Markdown document gives agents, as HTML comments on their own lines.
|
|
3
|
+
// <!-- will/1 keep --> ... <!-- /will --> keep: no change; append: add after the region's text only; edit: any change
|
|
4
|
+
export type WillLaw = 'keep' | 'append' | 'edit';
|
|
5
|
+
export type WillFault = 'malformed_marker' | 'unpaired_marker';
|
|
6
|
+
/** A paired region; `start` to `end` is its text between the marker lines (UTF-16 offsets). */
|
|
7
|
+
export interface WillRegion {
|
|
8
|
+
index: number; law: WillLaw; intent: string | null;
|
|
9
|
+
openerLine: number; openerStart: number; openerEnd: number; openerContentEnd: number;
|
|
10
|
+
closerLine: number; closerStart: number; closerEnd: number;
|
|
11
|
+
start: number; end: number;
|
|
12
|
+
}
|
|
13
|
+
export interface WillMarker { kind: 'open' | 'close' | 'near'; law: WillLaw | ''; intent: string | null; content: string; fault?: WillFault; line?: number; start?: number; end?: number; contentEnd?: number; }
|
|
14
|
+
export interface Will {
|
|
15
|
+
/** True when the text has any marker line. */
|
|
16
|
+
present: boolean;
|
|
17
|
+
/** The first fault, or ''. A document with a fault is all keep. */
|
|
18
|
+
fault: WillFault | '';
|
|
19
|
+
faults: {mode: WillFault; line: number; start: number; end: number}[];
|
|
20
|
+
/** Every marker line, as [start, end) with its terminator. */
|
|
21
|
+
blocks: {start: number; end: number}[];
|
|
22
|
+
markers: WillMarker[];
|
|
23
|
+
regions: WillRegion[];
|
|
24
|
+
text: string;
|
|
25
|
+
}
|
|
26
|
+
/** A quick test for any marker line. */
|
|
27
|
+
export function hasWillMarkers(text: string): boolean;
|
|
28
|
+
export function parseWill(text: string): Will;
|
|
29
|
+
/** The law over [start, end): 'keep' when a fault, a marker or a keep region is touched; 'append' inside an append region; else 'edit'. */
|
|
30
|
+
export function willGovern(will: Will, start: number, end: number): WillLaw;
|
|
31
|
+
/** The regions [start, end) touches (an empty range: the regions it stands in). */
|
|
32
|
+
export function willRegionsIn(will: Will, start: number, end: number): WillRegion[];
|
|
33
|
+
/** The region or marker line [start, end) touches, or null. */
|
|
34
|
+
export function willTouchesMarker(will: Will, start: number, end: number): WillRegion | {law: 'keep'} | null;
|
|
35
|
+
/** The intent words of the one region wholly containing [start, end), or ''. */
|
|
36
|
+
export function willIntentOf(will: Will, start: number, end: number): string;
|
|
37
|
+
/** Reads one line as a marker, or null. */
|
|
38
|
+
export function willMarkerOf(line: string): WillMarker | null;
|
|
39
|
+
/** The width of a line terminator ending at `last` (char code), with `prior` before it: 0, 1 or 2. */
|
|
40
|
+
export function terminatorWidth(last: number, prior: number): number;
|
|
41
|
+
/** The text without one trailing line terminator. */
|
|
42
|
+
export function stripOneTerminator(text: string): string;
|
|
@@ -19,7 +19,7 @@ const SPACING_SELECTORS = Object.freeze({paragraph: 'p', h1: 'h1', h2: 'h2', h3:
|
|
|
19
19
|
list: ':is(ul,ol)', item: 'li', table: ':is(table,.table-scroll-wrap)', picture: 'p[data-md-picture]',
|
|
20
20
|
drawing: ':is(.diagram-block,p[data-md-picture]:has(img[data-md-svg],img[src^="data:image/svg+xml"]))', quote: 'blockquote',
|
|
21
21
|
code: 'pre', rule: 'hr:not(.footnotes-sep):not([data-md-break])', footnote: '.footnotes', toc: '.rapier-jump-list'});
|
|
22
|
-
const HEADING_LINE = [
|
|
22
|
+
const HEADING_LINE = [1.75 / 2.2, 1.5 / 1.75, 1.2 / 1.5, 1 / 1.25, .9 / 1.1, .82];
|
|
23
23
|
|
|
24
24
|
export function normalizeDocumentSpacing(value) {
|
|
25
25
|
if (typeof value === 'string') {
|
|
@@ -68,7 +68,7 @@ export function wordDocumentSettings(settings) {
|
|
|
68
68
|
}
|
|
69
69
|
export function documentHeadingSize(settings, level) {
|
|
70
70
|
const body = (settings?.fontsize?.unit || 1.1) * 12;
|
|
71
|
-
return body * (settings?.word ? [16/11,13/11,12/11,1,1,1] : [2.
|
|
71
|
+
return body * (settings?.word ? [16/11,13/11,12/11,1,1,1] : [2.2,1.75,1.5,1.25,1.1,1])[level - 1];
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
export function documentSpacingCss(settings, scope = '.md-render', defaults = false) {
|
|
@@ -474,6 +474,7 @@ export function mountHtmlBlocks(doc, options = {}) {
|
|
|
474
474
|
if (info) {pixels += info.width * info.height; if (pixels > 24000000) throw new Error('HTML image pixel budget'); admitted[url] = {width: info.width, height: info.height, animated: !!info.animated};}
|
|
475
475
|
if (match[1].toLowerCase() === 'image/svg+xml') {
|
|
476
476
|
const svgText = new win.TextDecoder('utf-8', {fatal: true}).decode(bytes);
|
|
477
|
+
if (/<!\s*(?:DOCTYPE|ENTITY)\b/i.test(svgText)) throw new Error('SVG declarations are unsupported');
|
|
477
478
|
const parsed = new win.DOMParser().parseFromString(svgText, 'image/svg+xml');
|
|
478
479
|
if (parsed.querySelector('parsererror') || parsed.documentElement.localName !== 'svg') throw new Error('Invalid SVG image');
|
|
479
480
|
const svg = doc.importNode(parsed.documentElement, true);
|
|
@@ -618,8 +619,9 @@ export function mountHtmlBlocks(doc, options = {}) {
|
|
|
618
619
|
|
|
619
620
|
// Cold export uses the same opaque renderer, independently of the visible editor
|
|
620
621
|
// and its caches. The caller owns original bytes; only detached display nodes change.
|
|
621
|
-
export async function materializeObjects(root, {signal, width = 640, imageInfo, poster, bestEffort = false, issues = []} = {}) {
|
|
622
|
-
|
|
622
|
+
export async function materializeObjects(root, {signal, width = 640, imageInfo, poster, bestEffort = false, issues = [], document: live} = {}) {
|
|
623
|
+
// An export root is parsed in an inert document (no window, no body); the caller names the page whose sandboxed frames draw its objects.
|
|
624
|
+
const doc = root.ownerDocument.defaultView ? root.ownerDocument : live, win = doc.defaultView;
|
|
623
625
|
const images = [...root.querySelectorAll('img[data-rapier-html],img[data-rapier-animation]')];
|
|
624
626
|
if (images.length > 64) throw new Error('Object export count budget');
|
|
625
627
|
for (const image of images) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rapier-markdown-kit",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.8",
|
|
4
4
|
"description": "Read, render and write Self-contained Markdown: one .md file that carries its pictures, drawings and layout, and that any Markdown app can read. HTML renderer, stylesheet, text layout, Will/1 agent permissions, and an edit history with authorship and offline merge. MIT, no dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"markdown",
|
|
@@ -40,17 +40,34 @@
|
|
|
40
40
|
"main": "./dist/kit/index.mjs",
|
|
41
41
|
"exports": {
|
|
42
42
|
".": "./dist/kit/index.mjs",
|
|
43
|
+
"./markdown-it": {
|
|
44
|
+
"types": "./dist/kit/markdown-it.d.mts",
|
|
45
|
+
"default": "./dist/kit/markdown-it.mjs"
|
|
46
|
+
},
|
|
43
47
|
"./layout": "./dist/kit/layout.mjs",
|
|
44
48
|
"./marks": "./dist/kit/marks.mjs",
|
|
45
49
|
"./model": "./dist/kit/model.mjs",
|
|
46
50
|
"./typography": "./dist/kit/typography.mjs",
|
|
47
|
-
"./will":
|
|
51
|
+
"./will": {
|
|
52
|
+
"types": "./dist/kit/will.d.mts",
|
|
53
|
+
"default": "./dist/kit/will.mjs"
|
|
54
|
+
},
|
|
48
55
|
"./assets": "./dist/kit/assets.mjs",
|
|
49
56
|
"./style.css": "./style.css",
|
|
50
57
|
"./package.json": "./package.json",
|
|
51
58
|
"./render": "./dist/kit/render-entry.mjs",
|
|
52
|
-
"./rapier-ledger":
|
|
53
|
-
|
|
59
|
+
"./rapier-ledger": {
|
|
60
|
+
"types": "./dist/kit/ledger.d.mts",
|
|
61
|
+
"default": "./dist/kit/ledger.mjs"
|
|
62
|
+
},
|
|
63
|
+
"./pdf": {
|
|
64
|
+
"types": "./dist/kit/pdf-source.d.mts",
|
|
65
|
+
"default": "./dist/kit/pdf-source.mjs"
|
|
66
|
+
},
|
|
67
|
+
"./ledger/carried": {
|
|
68
|
+
"types": "./dist/kit/ledger/carried.d.mts",
|
|
69
|
+
"default": "./dist/kit/ledger/carried.mjs"
|
|
70
|
+
}
|
|
54
71
|
},
|
|
55
72
|
"repository": {
|
|
56
73
|
"type": "git",
|
package/style.css
CHANGED
|
@@ -251,8 +251,9 @@
|
|
|
251
251
|
max-height: 9rem;
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
-
/* Type keeps the reference line model: body 17.6/28 px at M;
|
|
255
|
-
|
|
254
|
+
/* Type keeps the reference line model: body 17.6/28 px at M; h1 (2.2 times the body) occupies one
|
|
255
|
+
and three quarter body lines, h2 (1.75) and h3 (1.5) one and a half, h4 to h6 one, so a wrapped
|
|
256
|
+
heading sets about 1.3 of its size. Compact margins use the body's size: 8 px after prose, 12 after other
|
|
256
257
|
blocks, 20 before headings and 4 after them at M. Sibling margins collapse to their maximum;
|
|
257
258
|
the editor's derived neighbor gap carries the same boundary inside its measured wrapper. */
|
|
258
259
|
.md-render{
|
|
@@ -263,8 +264,8 @@
|
|
|
263
264
|
.md-render h1,.md-render h2,.md-render h3,.md-render h4,.md-render h5,.md-render h6{
|
|
264
265
|
font-family:var(--md-font-sans);letter-spacing:-0.01em;
|
|
265
266
|
line-height:var(--md-line);margin:var(--md-gap-heading-before) 0 var(--md-gap-heading-after);color:var(--md-heading-color);text-wrap:balance}
|
|
266
|
-
.md-render h1{font-size:calc(var(--md-text-body) * 2.
|
|
267
|
-
.md-render h2{font-size:calc(var(--md-text-body) * 1.
|
|
267
|
+
.md-render h1{font-size:calc(var(--md-text-body) * 2.2);line-height:calc(var(--md-line) * 1.75);font-weight:var(--md-fw-semibold)}
|
|
268
|
+
.md-render h2{font-size:calc(var(--md-text-body) * 1.75);line-height:calc(var(--md-line) * 1.5);font-weight:var(--md-fw-semibold)}
|
|
268
269
|
.md-render h3{font-size:calc(var(--md-text-body) * 1.5);line-height:calc(var(--md-line) * 1.5);font-weight:var(--md-fw-semibold)}
|
|
269
270
|
.md-render h4{font-size:calc(var(--md-text-body) * 1.25);font-weight:var(--md-fw-semibold)}
|
|
270
271
|
/* h5/h6 complete the six-level hierarchy: modest but owned (explicit size/weight/color), never
|
|
@@ -411,7 +412,7 @@
|
|
|
411
412
|
.md-render li{margin:0 0 calc(var(--md-line) / 4)}
|
|
412
413
|
.md-render li:last-child{margin-bottom:0}
|
|
413
414
|
.md-render li>ul,.md-render li>ol{margin:calc(var(--md-line) / 4) 0 0}
|
|
414
|
-
.md-render a{color:var(--md-color-accent-text);text-decoration:underline;text-underline-offset:2px}
|
|
415
|
+
.md-render a{color:var(--md-color-accent-text);text-decoration:underline;text-underline-offset:2px;unicode-bidi:isolate}
|
|
415
416
|
/* The house focus (rapier-app.css) in the document and on the exported page: the underline thickens, no ring. */
|
|
416
417
|
.md-render a:focus-visible,.md-render ul.rapier-jump-list>li>a:focus-visible,.md-render .footnote-ref a:focus-visible,.md-render summary:focus-visible{outline:none;text-decoration:underline;text-decoration-thickness:3px;text-underline-offset:.2em}
|
|
417
418
|
|