rapier-markdown-kit 1.1.98 → 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
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// One reader of document-wide settings and the inherited font snapshot.
|
|
3
|
+
// Framing stays in spec/frontmatter.mjs. Reading never rewrites source.
|
|
4
|
+
// The live view reads fontsize, the three font scopes, linestretch and space. It ignores papersize,
|
|
5
|
+
// geometry and pagestyle: a scroll has no page. Word and the PDF read all of them.
|
|
6
|
+
import {frontMatterFrame, frontMatterLine} from './frontmatter.mjs';
|
|
7
|
+
import {normalizeFontFamily, fontFamilyCss, FONT_STACKS} from './md-marks.mjs';
|
|
8
|
+
|
|
9
|
+
// Twelve points are one reference unit (16 px at the default reading scale).
|
|
10
|
+
const NUMBER = /^(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
|
|
11
|
+
const SPACE = Object.freeze({paragraph: .5, heading: Object.freeze([1.5, .5]), block: 1});
|
|
12
|
+
export const DOCUMENT_LEADING = 1.75 / 1.1;
|
|
13
|
+
|
|
14
|
+
function measure(value, positive = false) {
|
|
15
|
+
const text = unquote(value);
|
|
16
|
+
const number = NUMBER.test(text) ? Number(text) : NaN;
|
|
17
|
+
return Number.isFinite(number) && number >= 0 && (!positive || number > 0) ? number : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fontSize(value) {
|
|
21
|
+
const match = /^(\d+(?:\.\d*)?|\.\d+)\s*(pt|px)$/i.exec(unquote(value));
|
|
22
|
+
if (!match) return null;
|
|
23
|
+
const number = measure(match[1], true), unit = match[2].toLowerCase();
|
|
24
|
+
if (number === null) return null;
|
|
25
|
+
const points = unit === 'pt' ? number : number * .75;
|
|
26
|
+
return Object.freeze({step: number + unit, unit: points / 12,
|
|
27
|
+
css: 'calc(' + number + ' / ' + (unit === 'pt' ? 12 : 16) + ' * var(--md-unit))', halfPoints: points * 2});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function leading(value) {
|
|
31
|
+
const factor = measure(value, true);
|
|
32
|
+
return factor === null ? null : Object.freeze({step: String(factor), factor,
|
|
33
|
+
css: 'calc(var(--md-text-body) * ' + factor + ')', line: Math.round(factor * 240)});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const PAPERS = Object.freeze({
|
|
37
|
+
letter: Object.freeze({name: 'letter', width: 12240, height: 15840, css: 'letter'}),
|
|
38
|
+
a4: Object.freeze({name: 'a4', width: 11906, height: 16838, css: 'A4'}),
|
|
39
|
+
a5: Object.freeze({name: 'a5', width: 8391, height: 11906, css: 'A5'}),
|
|
40
|
+
legal: Object.freeze({name: 'legal', width: 12240, height: 20160, css: 'legal'}),
|
|
41
|
+
});
|
|
42
|
+
const EMPTY = Object.freeze({
|
|
43
|
+
title: null, subtitle: null, lang: null, fontsize: null, mainfont: null, sansfont: null, monofont: null, defaultfont: null, linestretch: null,
|
|
44
|
+
papersize: null, geometry: null, pagestyle: null, space: null,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function unquote(value) {
|
|
48
|
+
const text = String(value == null ? '' : value).trim();
|
|
49
|
+
return text[0] === '"' || text[0] === "'" ? quotedValue(text) ?? text : text;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function marginOf(value) {
|
|
53
|
+
const match = /^margin\s*=\s*(\d+(?:\.\d+)?)(cm|mm|in|pt)$/i.exec(unquote(value));
|
|
54
|
+
if (!match) return null;
|
|
55
|
+
const number = Number(match[1]);
|
|
56
|
+
if (!Number.isFinite(number) || number < 0 || number > 6) return null;
|
|
57
|
+
const unit = match[2].toLowerCase();
|
|
58
|
+
const twips = Math.round(unit === 'in' ? number * 1440 : unit === 'pt' ? number * 20 : unit === 'cm' ? number * 1440 / 2.54 : number * 1440 / 25.4);
|
|
59
|
+
if (!Number.isSafeInteger(twips) || twips < 0 || twips > 1440 * 6) return null;
|
|
60
|
+
return Object.freeze({margin: match[1] + unit, twips});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Token spans retain the source's quotes, comments and collection punctuation.
|
|
64
|
+
function quotedEnd(text, start = 0, limit = text.length) {
|
|
65
|
+
const quote = text[start];
|
|
66
|
+
for (let at = start + 1; at < limit; at++) {
|
|
67
|
+
if (text[at] === '\\' && quote === '"') at++;
|
|
68
|
+
else if (text[at] === quote) {
|
|
69
|
+
if (quote === "'" && text[at + 1] === "'") at++;
|
|
70
|
+
else return at + 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function quotedValue(text) {
|
|
77
|
+
if (quotedEnd(text) !== text.length) return null;
|
|
78
|
+
if (text[0] === "'") return text.slice(1, -1).replace(/''/g, "'");
|
|
79
|
+
const escapes = {'0': '\0', a: '\x07', b: '\b', t: '\t', n: '\n', v: '\v', f: '\f', r: '\r', e: '\x1b', ' ': ' ', '"': '"', '/': '/', '\\': '\\', N: '\x85', _: '\xa0', L: '\u2028', P: '\u2029'};
|
|
80
|
+
let value = '';
|
|
81
|
+
for (let at = 1; at < text.length - 1; at++) {
|
|
82
|
+
if (text[at] !== '\\') { value += text[at]; continue; }
|
|
83
|
+
const code = text[++at], width = code === 'x' ? 2 : code === 'u' ? 4 : code === 'U' ? 8 : 0;
|
|
84
|
+
if (width) {
|
|
85
|
+
const digits = text.slice(at + 1, at + width + 1), point = Number.parseInt(digits, 16);
|
|
86
|
+
if (digits.length !== width || !/^[\da-f]+$/i.test(digits) || point > 0x10ffff || point >= 0xd800 && point <= 0xdfff) return null;
|
|
87
|
+
value += String.fromCodePoint(point); at += width;
|
|
88
|
+
} else if (Object.hasOwn(escapes, code)) value += escapes[code];
|
|
89
|
+
else return null;
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function keyOf(text) {
|
|
95
|
+
return (text[0] === '"' || text[0] === "'" ? quotedValue(text) : text)?.toLowerCase() ?? null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function flowEnd(text, start, limit = text.length) {
|
|
99
|
+
const stack = [text[start] === '{' ? '}' : ']'];
|
|
100
|
+
for (let at = start + 1; at < limit; at++) {
|
|
101
|
+
const char = text[at];
|
|
102
|
+
if ((char === '"' || char === "'") && /[\s:[{,]/.test(text[at - 1])) {
|
|
103
|
+
const end = quotedEnd(text, at, limit);
|
|
104
|
+
if (end === null) return null;
|
|
105
|
+
at = end - 1;
|
|
106
|
+
} else if (char === '#' && /\s/.test(text[at - 1])) {
|
|
107
|
+
while (at < limit && text[at] !== '\r' && text[at] !== '\n') at++;
|
|
108
|
+
} else if (char === '[' || char === '{') stack.push(char === '{' ? '}' : ']');
|
|
109
|
+
else if (char === ']' || char === '}') {
|
|
110
|
+
if (stack.pop() !== char) return null;
|
|
111
|
+
if (!stack.length) return at + 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function fieldLine(line, indented = false) {
|
|
118
|
+
const indent = /^ */.exec(line.text)[0].length;
|
|
119
|
+
if (indented ? !indent : !!indent) return null;
|
|
120
|
+
let end = indent, key;
|
|
121
|
+
if (line.text[end] === '"' || line.text[end] === "'") {
|
|
122
|
+
end = quotedEnd(line.text, end);
|
|
123
|
+
if (end === null) return null;
|
|
124
|
+
key = keyOf(line.text.slice(indent, end));
|
|
125
|
+
while (/[ \t]/.test(line.text[end] || 'x')) end++;
|
|
126
|
+
if (line.text[end] !== ':') return null;
|
|
127
|
+
} else {
|
|
128
|
+
end = line.text.indexOf(':', indent);
|
|
129
|
+
if (end < 0 || end + 1 < line.text.length && !/[\s\[\]{},]/.test(line.text[end + 1])) return null;
|
|
130
|
+
const raw = line.text.slice(indent, end).trimEnd();
|
|
131
|
+
if (!raw || /^[?\[\]{},&*!|>]/.test(raw) || /\s#/.test(raw)) return null;
|
|
132
|
+
key = keyOf(raw);
|
|
133
|
+
}
|
|
134
|
+
let start = line.start + end + 1;
|
|
135
|
+
while (start < line.end && /[ \t]/.test(line.text[start - line.start])) start++;
|
|
136
|
+
return {key, start, end: start, line, indent, children: [], opaque: key === null, invalid: false};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function valueSpan(source, field, limit) {
|
|
140
|
+
let at = field.start;
|
|
141
|
+
const first = source[at];
|
|
142
|
+
// Tags and anchors may name other YAML values. They are retained, never rewritten by a control.
|
|
143
|
+
if (first === '&' || first === '*' || first === '!') {
|
|
144
|
+
field.opaque = true;
|
|
145
|
+
while (at < field.line.end && /[&*!]/.test(source[at])) {
|
|
146
|
+
while (at < field.line.end && !/\s/.test(source[at])) at++;
|
|
147
|
+
while (at < field.line.end && /[ \t]/.test(source[at])) at++;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const quoted = source[at] === '"' || source[at] === "'", flow = source[at] === '[' || source[at] === '{';
|
|
151
|
+
let end;
|
|
152
|
+
if (quoted || flow) {
|
|
153
|
+
end = quoted ? quotedEnd(source, at, limit) : flowEnd(source, at, limit);
|
|
154
|
+
if (end === null) { field.invalid = true; field.end = field.line.end; return field; }
|
|
155
|
+
const tailLine = frontMatterLine(source, end);
|
|
156
|
+
if (!/^[ \t]*(?:#.*)?$/.test(source.slice(end, Math.min(limit, tailLine.end)))) field.invalid = true;
|
|
157
|
+
field.multiline = end > field.line.end;
|
|
158
|
+
} else {
|
|
159
|
+
end = field.line.end;
|
|
160
|
+
for (let i = at; i < end; i++) if (source[i] === '#' && (i === at || /\s/.test(source[i - 1]))) { end = i; break; }
|
|
161
|
+
while (end > at && /[ \t]/.test(source[end - 1])) end--;
|
|
162
|
+
}
|
|
163
|
+
field.end = end;
|
|
164
|
+
field.block = /^[|>][+\-1-9]*$/.test(source.slice(at, field.end));
|
|
165
|
+
return field;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function mapFields(source, lines, indent) {
|
|
169
|
+
const fields = [];
|
|
170
|
+
let invalid = false, current = null;
|
|
171
|
+
const limit = lines.at(-1)?.end ?? 0;
|
|
172
|
+
for (const line of lines) {
|
|
173
|
+
if (current?.multiline && line.start < current.end) continue;
|
|
174
|
+
if (!line.text.trim() || /^\s*#/.test(line.text)) continue;
|
|
175
|
+
const ownIndent = /^ */.exec(line.text)[0].length;
|
|
176
|
+
if (ownIndent > indent && current) { current.children.push(line); continue; }
|
|
177
|
+
if (ownIndent !== indent) { invalid = true; continue; }
|
|
178
|
+
const field = fieldLine(line, indent > 0);
|
|
179
|
+
if (!field) { invalid = true; continue; }
|
|
180
|
+
current = valueSpan(source, field, limit);
|
|
181
|
+
if (field.invalid || field.key === null) invalid = true;
|
|
182
|
+
fields.push(field);
|
|
183
|
+
}
|
|
184
|
+
return {fields, invalid};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// An append must never enter an unclosed collection or quoted value, including one under an unrelated key.
|
|
188
|
+
function structureIsClosed(source, lines) {
|
|
189
|
+
let blockIndent = null, through = 0;
|
|
190
|
+
const limit = lines.at(-1)?.end ?? 0;
|
|
191
|
+
for (const line of lines) {
|
|
192
|
+
if (line.start < through || !line.text.trim() || /^\s*#/.test(line.text)) continue;
|
|
193
|
+
const indent = /^ */.exec(line.text)[0].length;
|
|
194
|
+
if (blockIndent !== null && indent > blockIndent) continue;
|
|
195
|
+
blockIndent = null;
|
|
196
|
+
let offset = indent;
|
|
197
|
+
while (/^-\s/.test(line.text.slice(offset))) offset += /^-[ \t]*/.exec(line.text.slice(offset))[0].length;
|
|
198
|
+
const content = {...line, start: line.start + offset, text: line.text.slice(offset)};
|
|
199
|
+
const field = fieldLine(content) || {start: content.start, line: content, children: []};
|
|
200
|
+
const span = valueSpan(source, field, limit);
|
|
201
|
+
if (span.invalid || field.key === null) return false;
|
|
202
|
+
if (span.block) blockIndent = indent;
|
|
203
|
+
if (span.multiline) through = span.end;
|
|
204
|
+
}
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function fieldsOf(source) {
|
|
209
|
+
const frame = frontMatterFrame(source), lines = [];
|
|
210
|
+
if (frame.closing) for (let at = frame.opening.next; at < frame.closing.start;) {
|
|
211
|
+
const line = frontMatterLine(source, at); lines.push(line); at = line.next;
|
|
212
|
+
}
|
|
213
|
+
const parsed = mapFields(source, lines, 0);
|
|
214
|
+
return {frame, fields: parsed.fields, invalid: parsed.invalid || !structureIsClosed(source, lines)};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function flowItems(source, start, stop) {
|
|
218
|
+
const items = [];
|
|
219
|
+
for (let at = start; at < stop;) {
|
|
220
|
+
if (!source.slice(at, stop).trim()) break;
|
|
221
|
+
let end = at;
|
|
222
|
+
for (; end < stop; end++) {
|
|
223
|
+
const char = source[end];
|
|
224
|
+
if ((char === '"' || char === "'") && (end === at || /[\s:[{,]/.test(source[end - 1]))) {
|
|
225
|
+
const next = quotedEnd(source, end, stop);
|
|
226
|
+
if (next === null) return null;
|
|
227
|
+
end = next - 1;
|
|
228
|
+
} else if (char === '[' || char === '{') {
|
|
229
|
+
const next = flowEnd(source, end, stop);
|
|
230
|
+
if (next === null) return null;
|
|
231
|
+
end = next - 1;
|
|
232
|
+
} else if (char === ',') break;
|
|
233
|
+
}
|
|
234
|
+
if (!source.slice(at, end).trim()) return null;
|
|
235
|
+
items.push({start: at, end, comma: end < stop ? end : null});
|
|
236
|
+
at = end + 1;
|
|
237
|
+
}
|
|
238
|
+
return items;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function spaceFields(source, field) {
|
|
242
|
+
if (!field || field.opaque || field.invalid || field.multiline) return null;
|
|
243
|
+
const value = source.slice(field.start, field.end);
|
|
244
|
+
if (value.startsWith('{') && value.endsWith('}') && !field.children.length) {
|
|
245
|
+
const chunks = flowItems(source, field.start + 1, field.end - 1), fields = [];
|
|
246
|
+
if (!chunks) return null;
|
|
247
|
+
for (const chunk of chunks) {
|
|
248
|
+
const start = chunk.start + source.slice(chunk.start, chunk.end).match(/^\s*/)[0].length;
|
|
249
|
+
const line = {start, end: chunk.end, next: chunk.end, text: source.slice(start, chunk.end), eol: ''};
|
|
250
|
+
const item = fieldLine(line);
|
|
251
|
+
if (!item) return null;
|
|
252
|
+
valueSpan(source, item, chunk.end);
|
|
253
|
+
if (item.invalid || item.key === null) return null;
|
|
254
|
+
fields.push({...item, entryStart: chunk.start, entryEnd: chunk.end, comma: chunk.comma});
|
|
255
|
+
}
|
|
256
|
+
return {fields, flow: true};
|
|
257
|
+
}
|
|
258
|
+
if (value) return null;
|
|
259
|
+
if (!field.children.length) return {fields: [], flow: false};
|
|
260
|
+
const indent = /^ */.exec(field.children[0].text)[0].length;
|
|
261
|
+
const parsed = mapFields(source, field.children, indent);
|
|
262
|
+
return parsed.invalid ? null : {...parsed, flow: false, indent};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function headingParts(source, field) {
|
|
266
|
+
if (field.opaque || field.invalid || field.multiline) return null;
|
|
267
|
+
let spans;
|
|
268
|
+
if (field.children.length) {
|
|
269
|
+
if (field.start !== field.end) return null;
|
|
270
|
+
let indent = null;
|
|
271
|
+
spans = field.children.map(line => {
|
|
272
|
+
const match = /^( +)-[ \t]+/.exec(line.text);
|
|
273
|
+
if (!match || indent !== null && match[1].length !== indent) return null;
|
|
274
|
+
indent = match[1].length;
|
|
275
|
+
const span = valueSpan(source, {start: line.start + match[0].length, line, children: []}, line.end);
|
|
276
|
+
return span.invalid || span.opaque ? null : span;
|
|
277
|
+
});
|
|
278
|
+
} else {
|
|
279
|
+
if (source[field.start] !== '[' || source[field.end - 1] !== ']') return null;
|
|
280
|
+
spans = flowItems(source, field.start + 1, field.end - 1);
|
|
281
|
+
}
|
|
282
|
+
if (!spans || spans.length !== 2 || spans.some(span => !span)) return null;
|
|
283
|
+
const values = spans.map(span => measure(source.slice(span.start, span.end)));
|
|
284
|
+
return values.includes(null) ? null : {values, spans};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function spaceOf(source, field) {
|
|
288
|
+
const parsed = spaceFields(source, field);
|
|
289
|
+
if (!parsed) return null;
|
|
290
|
+
const value = {...SPACE}, seen = new Set();
|
|
291
|
+
for (const item of parsed.fields) {
|
|
292
|
+
if (seen.has(item.key)) return null;
|
|
293
|
+
seen.add(item.key);
|
|
294
|
+
if (!Object.hasOwn(SPACE, item.key)) continue;
|
|
295
|
+
if (item.key === 'heading') {
|
|
296
|
+
const pair = headingParts(source, item);
|
|
297
|
+
if (!pair) return null;
|
|
298
|
+
value.heading = Object.freeze(pair.values);
|
|
299
|
+
} else {
|
|
300
|
+
if (item.children.length || item.opaque || item.invalid || item.multiline) return null;
|
|
301
|
+
value[item.key] = measure(source.slice(item.start, item.end));
|
|
302
|
+
if (value[item.key] === null) return null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return Object.keys(SPACE).some(key => seen.has(key)) ? Object.freeze(value) : null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function documentFont(value) {
|
|
309
|
+
const family = normalizeFontFamily(value);
|
|
310
|
+
if (!family) return null;
|
|
311
|
+
const named = Object.hasOwn(FONT_STACKS, family.toLowerCase()) ? FONT_STACKS[family.toLowerCase()] : null;
|
|
312
|
+
return Object.freeze(named ? {...named, family} : {family, stack: 'named', css: fontFamilyCss(family), word: family});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Font controls use the same source spans as the other document settings.
|
|
316
|
+
export function writeDocumentFontSetting(markdown, key, value) {
|
|
317
|
+
if (!['mainfont', 'sansfont', 'monofont', 'defaultfont'].includes(key)) throw new TypeError('font: unknown document key');
|
|
318
|
+
const family = value == null ? null : normalizeFontFamily(value);
|
|
319
|
+
if (value != null && !family) throw new TypeError('font: use one family name');
|
|
320
|
+
return updateDocumentSettings(markdown, {[key]: family});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// A language tag is metadata, never CSS or markup. Its source spelling stays untouched.
|
|
324
|
+
export function languageTag(value) {
|
|
325
|
+
if (typeof value !== 'string' || value.length > 128 || !/^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/i.test(value)) return null;
|
|
326
|
+
try { return Intl.getCanonicalLocales(value)[0] || null; }
|
|
327
|
+
catch (_) { return null; }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function documentLanguage(settings, device = globalThis.navigator?.language) {
|
|
331
|
+
return languageTag(settings?.lang) || languageTag(device) || 'en';
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Unrecognized fields remain authored bytes. A duplicate key has no single value.
|
|
335
|
+
export function readDocumentSettings(markdown) {
|
|
336
|
+
const source = String(markdown ?? ''), {frame, fields} = fieldsOf(source);
|
|
337
|
+
if (!frame.closing) return EMPTY;
|
|
338
|
+
const field = name => { const matches = fields.filter(item => item.key === name); return matches.length === 1 ? matches[0] : null; };
|
|
339
|
+
const take = name => { const item = field(name); return item && !item.children.length && !item.opaque && !item.invalid && !item.multiline && !item.block ? unquote(source.slice(item.start, item.end)) : null; };
|
|
340
|
+
const fontsize = fontSize(take('fontsize'));
|
|
341
|
+
const mainfont = documentFont(take('mainfont'));
|
|
342
|
+
const sansfont = documentFont(take('sansfont'));
|
|
343
|
+
const monofont = documentFont(take('monofont'));
|
|
344
|
+
const defaultfont = documentFont(take('defaultfont'));
|
|
345
|
+
const linestretch = leading(take('linestretch'));
|
|
346
|
+
const papersize = PAPERS[String(take('papersize') || '').toLowerCase()] || null;
|
|
347
|
+
const geometry = take('geometry') != null ? marginOf(take('geometry')) : null;
|
|
348
|
+
const page = String(take('pagestyle') || '').toLowerCase();
|
|
349
|
+
return Object.freeze({
|
|
350
|
+
title: take('title') || null, subtitle: take('subtitle') || null, lang: languageTag(take('lang')),
|
|
351
|
+
fontsize, mainfont, sansfont, monofont, defaultfont, linestretch, space: spaceOf(source, field('space')), papersize, geometry,
|
|
352
|
+
pagestyle: page === 'plain' || page === 'empty' ? page : null,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function spaceValue(value) {
|
|
357
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !Object.hasOwn(SPACE, key))) return null;
|
|
358
|
+
const result = {...SPACE, ...value};
|
|
359
|
+
if (!Array.isArray(result.heading) || result.heading.length !== 2) return null;
|
|
360
|
+
return [result.paragraph, ...result.heading, result.block].every(number => typeof number === 'number' && Number.isFinite(number) && number >= 0) ? result : null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Change only supplied settings. Unrelated YAML, comments, line endings, BOM and body are never reprinted.
|
|
364
|
+
export function updateDocumentSettings(markdown, patch) {
|
|
365
|
+
const source = String(markdown ?? ''), parsed = fieldsOf(source), {frame, fields} = parsed, before = readDocumentSettings(source);
|
|
366
|
+
const changes = [], append = [];
|
|
367
|
+
const eol = frame.closing ? frame.opening.eol : (source.match(/\r\n|\r|\n/) || ['\n'])[0];
|
|
368
|
+
const encodeSpace = value => '{ paragraph: ' + value.paragraph + ', heading: [' + value.heading.join(', ') + '], block: ' + value.block + ' }';
|
|
369
|
+
const same = (left, right) => Math.abs(left - right) <= Number.EPSILON * Math.max(Math.abs(left), Math.abs(right)) * 4;
|
|
370
|
+
const refuse = key => { throw new RangeError('Edit this setting in source: ' + key); };
|
|
371
|
+
const removeLine = (line, end, indent = 0) => {
|
|
372
|
+
const suffix = source.slice(end, line.end);
|
|
373
|
+
changes.push({start: line.start, end: line.next, text: suffix.trimStart().startsWith('#') ? ' '.repeat(indent) + suffix + line.eol : ''});
|
|
374
|
+
};
|
|
375
|
+
const replaceValue = (field, text) => changes.push({start: field.start, end: field.end,
|
|
376
|
+
text: (source[field.start - 1] === ':' ? ' ' : '') + text + (source[field.end] === '#' ? ' ' : '')});
|
|
377
|
+
for (const [key, supplied] of Object.entries(patch || {})) {
|
|
378
|
+
const font = ['mainfont', 'sansfont', 'monofont', 'defaultfont'].includes(key);
|
|
379
|
+
if (!font && !['fontsize', 'linestretch', 'space'].includes(key)) throw new TypeError('Unknown document setting: ' + key);
|
|
380
|
+
const matches = fields.filter(item => item.key === key);
|
|
381
|
+
if (matches.length > 1) throw new RangeError('Duplicate document setting: ' + key);
|
|
382
|
+
const field = matches[0];
|
|
383
|
+
const value = supplied === null ? null : font ? documentFont(supplied) : key === 'fontsize' ? fontSize(supplied) : key === 'linestretch' ? leading(supplied) : spaceValue(supplied);
|
|
384
|
+
if (supplied !== null && !value) throw new TypeError('Invalid document setting: ' + key);
|
|
385
|
+
const encoded = value === null ? '' : font ? JSON.stringify(value.family) : key === 'fontsize' ? value.step : key === 'linestretch' ? String(value.factor) : encodeSpace(value);
|
|
386
|
+
if (key !== 'space' && value !== null && before[key] && (font ? before[key].family === value.family : key === 'fontsize' ? same(before.fontsize.unit, value.unit) : before.linestretch.factor === value.factor)) continue;
|
|
387
|
+
if (!field) { if (value !== null) append.push(key + ': ' + encoded + eol); continue; }
|
|
388
|
+
if (field.opaque || field.invalid || field.multiline || field.block) refuse(key);
|
|
389
|
+
if (key === 'space') {
|
|
390
|
+
const map = spaceFields(source, field);
|
|
391
|
+
if (!map) refuse(key);
|
|
392
|
+
const known = map.fields.filter(item => Object.hasOwn(SPACE, item.key));
|
|
393
|
+
if (new Set(map.fields.map(item => item.key)).size !== map.fields.length) throw new RangeError('Duplicate document setting: space');
|
|
394
|
+
if (known.length && !before.space) refuse(key);
|
|
395
|
+
if (value === null) {
|
|
396
|
+
const unknown = map.fields.length !== known.length;
|
|
397
|
+
for (const item of known) {
|
|
398
|
+
if (map.flow) {
|
|
399
|
+
if (unknown) changes.push({start: item.entryStart, end: item.comma === null ? item.entryEnd : item.comma + 1, text: ''});
|
|
400
|
+
} else {
|
|
401
|
+
for (const child of item.children) {
|
|
402
|
+
const own = headingParts(source, item)?.spans.find(span => span.line === child);
|
|
403
|
+
if (!own) refuse(key);
|
|
404
|
+
removeLine(child, own.end, /^ */.exec(child.text)[0].length);
|
|
405
|
+
}
|
|
406
|
+
removeLine(item.line, item.end, item.indent);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (!unknown) removeLine(field.line, field.end);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (before.space && encodeSpace(before.space) === encoded) continue;
|
|
413
|
+
if (!map.fields.length) { replaceValue(field, encoded); continue; }
|
|
414
|
+
const missing = [];
|
|
415
|
+
for (const name of Object.keys(SPACE)) {
|
|
416
|
+
if (before.space && JSON.stringify(before.space[name]) === JSON.stringify(value[name])) continue;
|
|
417
|
+
const item = map.fields.find(item => item.key === name);
|
|
418
|
+
const text = name === 'heading' ? '[' + value.heading.join(', ') + ']' : String(value[name]);
|
|
419
|
+
if (!item) { missing.push(name + ': ' + text); continue; }
|
|
420
|
+
if (name === 'heading') {
|
|
421
|
+
const pair = headingParts(source, item);
|
|
422
|
+
for (let i = 0; i < 2; i++) if (pair.values[i] !== value.heading[i]) {
|
|
423
|
+
const span = pair.spans[i], raw = source.slice(span.start, span.end);
|
|
424
|
+
changes.push({start: span.start + raw.length - raw.trimStart().length, end: span.end - raw.length + raw.trimEnd().length, text: String(value.heading[i])});
|
|
425
|
+
}
|
|
426
|
+
} else replaceValue(item, text);
|
|
427
|
+
}
|
|
428
|
+
if (missing.length) {
|
|
429
|
+
if (map.flow) {
|
|
430
|
+
const last = map.fields.at(-1);
|
|
431
|
+
changes.push({start: field.end - 1, end: field.end - 1, text: (last.comma === null ? ', ' : ' ') + missing.join(', ')});
|
|
432
|
+
} else {
|
|
433
|
+
const last = field.children.at(-1), indent = ' '.repeat(map.indent);
|
|
434
|
+
changes.push({start: last.next, end: last.next, text: missing.map(item => indent + item + eol).join('')});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (field.children.length) refuse(key);
|
|
440
|
+
if (value === null) removeLine(field.line, field.end);
|
|
441
|
+
else replaceValue(field, encoded);
|
|
442
|
+
}
|
|
443
|
+
if (!changes.length && !append.length) return source;
|
|
444
|
+
if (parsed.invalid) throw new RangeError('Edit this front matter in source before changing settings');
|
|
445
|
+
if (!frame.closing) {
|
|
446
|
+
if (/^--- *$/.test(frame.opening.text) && frame.opening.eol) throw new RangeError('Close the front matter before changing settings');
|
|
447
|
+
return frame.bom + '---' + eol + append.join('') + '---' + eol + source.slice(frame.bom.length);
|
|
448
|
+
}
|
|
449
|
+
if (append.length) changes.push({start: frame.closing.start, end: frame.closing.start, text: append.join('')});
|
|
450
|
+
let next = source;
|
|
451
|
+
for (const change of changes.sort((a, b) => b.start - a.start || b.end - a.end)) next = next.slice(0, change.start) + change.text + next.slice(change.end);
|
|
452
|
+
return next;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// The page's <title>, the PDF's document title. Subtitle follows the title.
|
|
456
|
+
// An absent title returns '' so the caller keeps the filename.
|
|
457
|
+
export function documentTitle(settings) {
|
|
458
|
+
if (!settings || !settings.title) return '';
|
|
459
|
+
return settings.subtitle ? settings.title + ' \u2014 ' + settings.subtitle : settings.title;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// A supplied personal default replaces only the inherited snapshot. Explicit scopes win.
|
|
463
|
+
// Omitted means use the portable source; null means the reader's factory default.
|
|
464
|
+
export function documentSettingsFonts(settings, defaultFont) {
|
|
465
|
+
const inherited = defaultFont === undefined ? settings?.defaultfont : documentFont(defaultFont);
|
|
466
|
+
const main = settings?.mainfont || inherited;
|
|
467
|
+
return {main, sans: settings?.sansfont || main, mono: settings?.monofont};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function documentSettingsStyle(settings, defaultFont) {
|
|
471
|
+
if (!settings) return '';
|
|
472
|
+
const decls = [];
|
|
473
|
+
if (settings.fontsize) decls.push('--md-text-body:' + settings.fontsize.css);
|
|
474
|
+
if (settings.linestretch || settings.fontsize) decls.push('--md-line:calc(var(--md-text-body) * ' + (settings.linestretch?.factor ?? DOCUMENT_LEADING) + ')');
|
|
475
|
+
if (settings.space) {
|
|
476
|
+
decls.push('--md-space-paragraph:' + settings.space.paragraph, '--md-space-heading-before:' + settings.space.heading[0],
|
|
477
|
+
'--md-space-heading-after:' + settings.space.heading[1], '--md-space-block:' + settings.space.block);
|
|
478
|
+
}
|
|
479
|
+
const {main, sans, mono} = documentSettingsFonts(settings, defaultFont);
|
|
480
|
+
if (sans) decls.push('--md-font-sans:' + (sans.css || fontFamilyCss(sans.family)));
|
|
481
|
+
if (main) decls.push('font-family:' + (main.css || fontFamilyCss(main.family)));
|
|
482
|
+
if (mono) decls.push('--md-font-mono:' + (mono.css || fontFamilyCss(mono.family)));
|
|
483
|
+
return decls.length ? '.md-render{' + decls.join(';') + '}' : '';
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Null when the page keys do not change the print sheet. The view never asks.
|
|
487
|
+
// The default rule stays the writer's own: size:auto; margin:12mm 14mm 14mm.
|
|
488
|
+
export function documentSettingsPageRule(settings) {
|
|
489
|
+
if (!settings || (!settings.papersize && !settings.geometry && settings.pagestyle !== 'plain')) return null;
|
|
490
|
+
const size = settings.papersize ? settings.papersize.css : 'auto';
|
|
491
|
+
const margin = settings.geometry ? settings.geometry.margin : '12mm 14mm 14mm';
|
|
492
|
+
const numbers = settings.pagestyle === 'plain' ? '@bottom-center{content:counter(page)}' : '';
|
|
493
|
+
return '@page{size:' + size + ';margin:' + margin + (numbers ? ';' + numbers : '') + '}';
|
|
494
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Framing owns only offsets. No field interpretation or source normalization belongs here.
|
|
3
|
+
export function frontMatterLine(text, start) {
|
|
4
|
+
let end = start;
|
|
5
|
+
while (end < text.length && text[end] !== '\r' && text[end] !== '\n') end++;
|
|
6
|
+
const eol = text[end] === '\r' && text[end + 1] === '\n' ? '\r\n' : text[end] === '\r' || text[end] === '\n' ? text[end] : '';
|
|
7
|
+
return {start, end, next: end + eol.length, text: text.slice(start, end), eol};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function frontMatterFrame(text) {
|
|
11
|
+
if (typeof text !== 'string') throw new TypeError('Front matter needs text');
|
|
12
|
+
const bom = text[0] === '\uFEFF' ? '\uFEFF' : '';
|
|
13
|
+
const opening = frontMatterLine(text, bom.length);
|
|
14
|
+
let closing = null;
|
|
15
|
+
if (/^--- *$/.test(opening.text) && opening.eol) {
|
|
16
|
+
for (let at = opening.next; at < text.length;) {
|
|
17
|
+
const line = frontMatterLine(text, at);
|
|
18
|
+
if (/^(?:---|\.\.\.) *$/.test(line.text)) { closing = line; break; }
|
|
19
|
+
at = line.next;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return {bom, opening, closing};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function splitOpeningFrontmatter(markdown) {
|
|
26
|
+
const source = String(markdown == null ? '' : markdown);
|
|
27
|
+
const {closing} = frontMatterFrame(source);
|
|
28
|
+
const bodyOffset = closing ? closing.next : 0;
|
|
29
|
+
return {frontmatter: closing ? source.slice(0, bodyOffset) : null, body: source.slice(bodyOffset), bodyOffset};
|
|
30
|
+
}
|