sdocs-dev 1.6.2 → 1.13.0
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/bin/sdocs-bridge.js +974 -0
- package/bin/sdocs-dev.js +152 -2102
- package/bin/sdocs-icon-names.js +1965 -0
- package/lib/agent-block.js +245 -0
- package/lib/agent-files.js +162 -0
- package/lib/bridge-commands.js +171 -0
- package/lib/cells-transclude.js +114 -0
- package/lib/cells-verify.js +165 -0
- package/lib/commands.js +291 -0
- package/lib/constants.js +283 -0
- package/lib/help-text.js +2759 -0
- package/lib/io.js +177 -0
- package/lib/library-autostart.js +145 -0
- package/lib/library-commands.js +307 -0
- package/lib/library-ephemeral.js +111 -0
- package/lib/library-index.js +285 -0
- package/lib/library-paths.js +20 -0
- package/lib/library-scan.js +258 -0
- package/lib/library-server.js +400 -0
- package/lib/library-store.js +141 -0
- package/lib/router.js +52 -0
- package/lib/safe.js +200 -0
- package/lib/setup.js +351 -0
- package/lib/short-link.js +105 -0
- package/lib/styles.js +91 -0
- package/lib/update-check.js +163 -0
- package/lib/url.js +111 -0
- package/package.json +5 -16
- package/shared/sdocs-cells-formula.js +485 -0
- package/shared/sdocs-cells.js +389 -0
- package/shared/sdocs-contrast.js +196 -0
- package/shared/sdocs-form-block.js +605 -0
- package/shared/sdocs-library-tags.js +41 -0
- package/{public → shared}/sdocs-styles.js +134 -5
- package/README.md +0 -149
- /package/{public → shared}/sdocs-slugify.js +0 -0
- /package/{public → shared}/sdocs-yaml.js +0 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// sdocs-cells.js - pure grid data model for ```cells fenced blocks.
|
|
2
|
+
//
|
|
3
|
+
// This is the BACKBONE of the cells/sheets feature. It is deliberately
|
|
4
|
+
// just the model: parse a block body into an addressable grid of cells.
|
|
5
|
+
// Every renderer (the inline grid, the future fullscreen sheet, the
|
|
6
|
+
// future editor) and the exporter read FROM this model; none of them is
|
|
7
|
+
// the source of truth. The DOM is always a view, never the data.
|
|
8
|
+
//
|
|
9
|
+
// A cell is two values from day one:
|
|
10
|
+
// { raw, value, type }
|
|
11
|
+
// raw - exactly what the source held (a literal today, a formula
|
|
12
|
+
// string like "=B2-B3" tomorrow). Never rewritten.
|
|
13
|
+
// value - the computed display value. In v1 (no formula engine) this
|
|
14
|
+
// equals the parsed literal. When formulas land, only the
|
|
15
|
+
// compute step changes; raw already carries what it needs.
|
|
16
|
+
// type - 'number' | 'text' | 'empty', drives alignment + display.
|
|
17
|
+
//
|
|
18
|
+
// The block body is CSV. That is on purpose: inline raw data and a future
|
|
19
|
+
// {{file.csv}} transclusion then share one parser and one model, so those
|
|
20
|
+
// two features are one code path rather than two with a seam between them.
|
|
21
|
+
//
|
|
22
|
+
// UMD so Node tests can require it directly; no DOM, no window.SDocs here.
|
|
23
|
+
(function (exports) {
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
// Bijective base-26 spreadsheet column name. 0 -> A, 25 -> Z, 26 -> AA,
|
|
27
|
+
// 701 -> ZZ, 702 -> AAA. Matches how every spreadsheet labels columns.
|
|
28
|
+
function colName(index) {
|
|
29
|
+
var i = index + 1;
|
|
30
|
+
var s = '';
|
|
31
|
+
while (i > 0) {
|
|
32
|
+
var rem = (i - 1) % 26;
|
|
33
|
+
s = String.fromCharCode(65 + rem) + s;
|
|
34
|
+
i = Math.floor((i - 1) / 26);
|
|
35
|
+
}
|
|
36
|
+
return s;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Inverse of colName: a column letter -> 0-based index ("A" -> 0, "AA" -> 26).
|
|
40
|
+
// Returns -1 for anything that isn't a run of A-Z letters.
|
|
41
|
+
function colIndex(letter) {
|
|
42
|
+
var s = String(letter).toUpperCase();
|
|
43
|
+
var n = 0;
|
|
44
|
+
for (var i = 0; i < s.length; i++) {
|
|
45
|
+
var c = s.charCodeAt(i) - 64; // A -> 1
|
|
46
|
+
if (c < 1 || c > 26) return -1;
|
|
47
|
+
n = n * 26 + c;
|
|
48
|
+
}
|
|
49
|
+
return s.length ? n - 1 : -1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Strict literal number: optional sign, digits, optional single decimal.
|
|
53
|
+
// No thousands separators in v1 - a bare "1,000" is two CSV fields, and a
|
|
54
|
+
// quoted "1,000" stays text rather than guessing the user's locale.
|
|
55
|
+
var NUMBER_RE = /^-?\d+(?:\.\d+)?$/;
|
|
56
|
+
|
|
57
|
+
// Classify a raw field string into a cell record. The trimmed text drives
|
|
58
|
+
// type detection; `raw` is preserved verbatim for display and round-trip.
|
|
59
|
+
function classify(raw) {
|
|
60
|
+
var t = String(raw).trim();
|
|
61
|
+
if (t === '') return { raw: raw, value: '', type: 'empty' };
|
|
62
|
+
if (NUMBER_RE.test(t)) return { raw: raw, value: Number(t), type: 'number' };
|
|
63
|
+
return { raw: raw, value: t, type: 'text' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Parse a CSV string into an array of row arrays of field strings.
|
|
67
|
+
// Handles quoted fields with embedded commas / newlines and "" escapes.
|
|
68
|
+
// Lenient by design: there is no such thing as a malformed sheet, only a
|
|
69
|
+
// ragged one (which parseCells pads).
|
|
70
|
+
function parseCsv(src) {
|
|
71
|
+
var rows = [];
|
|
72
|
+
var row = [];
|
|
73
|
+
var field = '';
|
|
74
|
+
var inQuotes = false;
|
|
75
|
+
var i = 0;
|
|
76
|
+
var n = src.length;
|
|
77
|
+
while (i < n) {
|
|
78
|
+
var ch = src.charAt(i);
|
|
79
|
+
if (inQuotes) {
|
|
80
|
+
if (ch === '"') {
|
|
81
|
+
if (src.charAt(i + 1) === '"') { field += '"'; i += 2; continue; }
|
|
82
|
+
inQuotes = false; i++; continue;
|
|
83
|
+
}
|
|
84
|
+
field += ch; i++; continue;
|
|
85
|
+
}
|
|
86
|
+
if (ch === '"') { inQuotes = true; i++; continue; }
|
|
87
|
+
if (ch === ',') { row.push(field); field = ''; i++; continue; }
|
|
88
|
+
if (ch === '\r') { i++; continue; }
|
|
89
|
+
if (ch === '\n') { row.push(field); rows.push(row); row = []; field = ''; i++; continue; }
|
|
90
|
+
field += ch; i++;
|
|
91
|
+
}
|
|
92
|
+
row.push(field);
|
|
93
|
+
rows.push(row);
|
|
94
|
+
return rows;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A bare {{path/to/file.csv}} reference (optionally with a :range suffix).
|
|
98
|
+
// Present only when a doc is opened WITHOUT the CLI (which otherwise bakes
|
|
99
|
+
// the data in); the renderer shows a "load it with sdoc" message.
|
|
100
|
+
var REFERENCE_RE = /^\{\{\s*([^}]+?)\s*\}\}$/;
|
|
101
|
+
// The machine-generated metadata line the CLI prepends to a baked block:
|
|
102
|
+
// sdoc-cells: source=report.csv range=B5:J32
|
|
103
|
+
// sdoc-cells: error="Could not read report.csv"
|
|
104
|
+
var DIRECTIVE_RE = /^sdoc-cells:\s*(.*)$/;
|
|
105
|
+
|
|
106
|
+
// Parse `key=value` / `key="quoted value"` pairs from a directive line.
|
|
107
|
+
function parseDirectives(str) {
|
|
108
|
+
var meta = {};
|
|
109
|
+
var re = /(\w+)=(?:"([^"]*)"|(\S+))/g;
|
|
110
|
+
var m;
|
|
111
|
+
while ((m = re.exec(str))) meta[m[1]] = m[2] !== undefined ? m[2] : m[3];
|
|
112
|
+
return meta;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Build the grid model from a ```cells block body.
|
|
116
|
+
// Returns { rows, cols, cells, empty } where cells is row-major, every row
|
|
117
|
+
// padded to `cols` with empty cells so the grid is rectangular. May instead
|
|
118
|
+
// return { unresolved: <ref> } or { error: <msg> } for reference blocks.
|
|
119
|
+
function parseCells(src) {
|
|
120
|
+
var text = String(src == null ? '' : src);
|
|
121
|
+
// Drop leading blank lines and all trailing whitespace (incl. the final
|
|
122
|
+
// newline marked emits) but keep interior blank rows - a blank row is
|
|
123
|
+
// meaningful in a sheet.
|
|
124
|
+
var trimmed = text.replace(/^\n+/, '').replace(/\s+$/, '');
|
|
125
|
+
if (trimmed === '') return { rows: 0, cols: 0, cells: [], empty: true };
|
|
126
|
+
|
|
127
|
+
// Peel leading directive lines (in any order, machine or author):
|
|
128
|
+
// sdoc-cells: source=... range=... error=... name=... (baked metadata)
|
|
129
|
+
// format: A=$ B=% C=plain (author column formats)
|
|
130
|
+
// `name` is the tab name. Authored as the fence info string (```cells
|
|
131
|
+
// Sales); the renderer + CLI normalise that into this directive so the
|
|
132
|
+
// name has one home in the model.
|
|
133
|
+
var source, range, formats, name;
|
|
134
|
+
var lines = trimmed.split('\n');
|
|
135
|
+
var idx = 0;
|
|
136
|
+
var FORMAT_RE = /^format:\s*(.*)$/i;
|
|
137
|
+
while (idx < lines.length) {
|
|
138
|
+
var dm = lines[idx].match(DIRECTIVE_RE);
|
|
139
|
+
var fm = lines[idx].match(FORMAT_RE);
|
|
140
|
+
if (dm) {
|
|
141
|
+
var meta = parseDirectives(dm[1]);
|
|
142
|
+
source = meta.source; range = meta.range;
|
|
143
|
+
if (meta.name != null) name = meta.name;
|
|
144
|
+
if (meta.error) return { empty: false, error: meta.error, source: source, name: name };
|
|
145
|
+
idx++; continue;
|
|
146
|
+
}
|
|
147
|
+
if (fm) { formats = parseFormats(fm[1]); idx++; continue; }
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
var body = lines.slice(idx).join('\n').replace(/\s+$/, '');
|
|
151
|
+
|
|
152
|
+
// Unresolved reference (after directives): the CLI never baked it.
|
|
153
|
+
var ref = body.match(REFERENCE_RE);
|
|
154
|
+
if (ref) return { empty: false, unresolved: ref[1], formats: formats, name: name };
|
|
155
|
+
if (body === '') return { rows: 0, cols: 0, cells: [], empty: true, source: source, range: range, formats: formats, name: name };
|
|
156
|
+
|
|
157
|
+
var raw = parseCsv(body);
|
|
158
|
+
var cols = 0;
|
|
159
|
+
for (var r = 0; r < raw.length; r++) {
|
|
160
|
+
if (raw[r].length > cols) cols = raw[r].length;
|
|
161
|
+
}
|
|
162
|
+
var cells = [];
|
|
163
|
+
for (var r2 = 0; r2 < raw.length; r2++) {
|
|
164
|
+
var line = raw[r2];
|
|
165
|
+
var out = [];
|
|
166
|
+
for (var c = 0; c < cols; c++) {
|
|
167
|
+
out.push(classify(c < line.length ? line[c] : ''));
|
|
168
|
+
}
|
|
169
|
+
cells.push(out);
|
|
170
|
+
}
|
|
171
|
+
return { rows: raw.length, cols: cols, cells: cells, empty: false, source: source, range: range, formats: formats, name: name };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Serialize a 2D array of raw cell strings back to CSV (RFC 4180 quoting:
|
|
175
|
+
// wrap in quotes and double any embedded quote when a field contains a
|
|
176
|
+
// comma, quote, or newline). The inverse of parseCsv for the copy actions.
|
|
177
|
+
function serializeCsv(rows) {
|
|
178
|
+
return rows.map(function (row) {
|
|
179
|
+
return row.map(function (v) {
|
|
180
|
+
var s = String(v == null ? '' : v);
|
|
181
|
+
return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
|
182
|
+
}).join(',');
|
|
183
|
+
}).join('\n');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Stats for a selected rectangle [r0..r1] x [c0..c1] of a model. Numbers
|
|
187
|
+
// drive sum/avg/min/max; count is every non-empty cell (Excel's "Count").
|
|
188
|
+
// Cells past the data (fullscreen padding) read as empty. fx (optional) is
|
|
189
|
+
// a recalc results grid aligned to this model's rows: a formula cell then
|
|
190
|
+
// contributes its computed value (errors count as non-empty, not numeric).
|
|
191
|
+
function selectionStats(model, r0, c0, r1, c1, fx) {
|
|
192
|
+
var count = 0, numericCount = 0, sum = 0, min = null, max = null;
|
|
193
|
+
for (var r = r0; r <= r1; r++) {
|
|
194
|
+
var line = model.cells[r];
|
|
195
|
+
for (var c = c0; c <= c1; c++) {
|
|
196
|
+
var cell = line && line[c];
|
|
197
|
+
if (!cell || cell.type === 'empty') continue;
|
|
198
|
+
count++;
|
|
199
|
+
var isNum = cell.type === 'number';
|
|
200
|
+
var v = cell.value;
|
|
201
|
+
// A formula cell's raw is "=..." text; its computed result decides.
|
|
202
|
+
if (fx && cell.raw && cell.raw.charAt(0) === '=') {
|
|
203
|
+
var fxCell = fx[r] && fx[r][c];
|
|
204
|
+
isNum = !!fxCell && fxCell.kind === 'number';
|
|
205
|
+
if (isNum) v = fxCell.value;
|
|
206
|
+
}
|
|
207
|
+
if (isNum) {
|
|
208
|
+
numericCount++;
|
|
209
|
+
sum += v;
|
|
210
|
+
if (min === null || v < min) min = v;
|
|
211
|
+
if (max === null || v > max) max = v;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
count: count,
|
|
217
|
+
numericCount: numericCount,
|
|
218
|
+
sum: sum,
|
|
219
|
+
avg: numericCount > 0 ? sum / numericCount : null,
|
|
220
|
+
min: min,
|
|
221
|
+
max: max,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Heuristic: is row 0 a header? True when row 0 has no numbers and there is
|
|
226
|
+
// numeric data below it - so a sort can keep it pinned to the top.
|
|
227
|
+
function looksLikeHeader(model) {
|
|
228
|
+
if (!model || model.rows < 2) return false;
|
|
229
|
+
var row0 = model.cells[0];
|
|
230
|
+
for (var c = 0; c < model.cols; c++) {
|
|
231
|
+
if (row0[c] && row0[c].type === 'number') return false;
|
|
232
|
+
}
|
|
233
|
+
for (var r = 1; r < model.rows; r++) {
|
|
234
|
+
var line = model.cells[r];
|
|
235
|
+
for (var c2 = 0; c2 < model.cols; c2++) {
|
|
236
|
+
if (line[c2] && line[c2].type === 'number') return true;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Sort key: numbers (rank 0) sort before text (rank 1); empty (rank 2) last.
|
|
243
|
+
// fxCell, when given, is the cell's computed formula result ({kind, value} /
|
|
244
|
+
// {kind: 'error', code}) and takes precedence: a formula sorts by the value
|
|
245
|
+
// the user sees, not by its "=..." source text. Errors sort with text.
|
|
246
|
+
function sortKey(cell, fxCell) {
|
|
247
|
+
if (fxCell && fxCell.kind === 'number') return { rank: 0, v: fxCell.value };
|
|
248
|
+
if (fxCell && fxCell.kind === 'error') return { rank: 1, v: String(fxCell.code || '').toLowerCase() };
|
|
249
|
+
if (!cell || cell.type === 'empty') return { rank: 2, v: 0 };
|
|
250
|
+
if (cell.type === 'number') return { rank: 0, v: cell.value };
|
|
251
|
+
return { rank: 1, v: String(cell.value).toLowerCase() };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// A summary row aggregates other rows: it holds at least one formula with a
|
|
255
|
+
// range spanning 2+ rows, e.g. =SUM(D2:D5). Per-row formulas (=B2*C2) refer
|
|
256
|
+
// to single cells and do not count. Used by sortRows to keep a trailing
|
|
257
|
+
// "Total" row pinned at the bottom instead of jumbling it into the data.
|
|
258
|
+
var ROW_RANGE_RE = /[A-Za-z]+([0-9]+)\s*:\s*[A-Za-z]+([0-9]+)/;
|
|
259
|
+
function isSummaryRow(row) {
|
|
260
|
+
if (!row) return false;
|
|
261
|
+
for (var i = 0; i < row.length; i++) {
|
|
262
|
+
var raw = row[i] && row[i].raw;
|
|
263
|
+
if (!raw || raw.charAt(0) !== '=') continue;
|
|
264
|
+
var m = ROW_RANGE_RE.exec(raw);
|
|
265
|
+
if (m && m[1] !== m[2]) return true; // the range spans rows
|
|
266
|
+
}
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Return the row order (array of original indices) sorting the model by a
|
|
271
|
+
// column. A view reorder - the model itself is not changed. Empty cells stay
|
|
272
|
+
// last either direction; a header row (when hasHeader) stays pinned to row 0;
|
|
273
|
+
// trailing summary rows (see isSummaryRow) stay pinned at the bottom.
|
|
274
|
+
// fx (optional) is a recalc results grid indexed by SOURCE row: formula
|
|
275
|
+
// cells then sort by their computed value instead of their source text.
|
|
276
|
+
function sortRows(model, col, dir, hasHeader, fx) {
|
|
277
|
+
var order = [];
|
|
278
|
+
for (var r = 0; r < model.rows; r++) order.push(r);
|
|
279
|
+
var start = hasHeader ? 1 : 0;
|
|
280
|
+
// Trailing summary rows (a Total / Average footer) sit outside the sort,
|
|
281
|
+
// in their original order.
|
|
282
|
+
var end = model.rows;
|
|
283
|
+
while (end > start && isSummaryRow(model.cells[end - 1])) end--;
|
|
284
|
+
var head = order.slice(0, start);
|
|
285
|
+
var tail = order.slice(end);
|
|
286
|
+
var body = order.slice(start, end);
|
|
287
|
+
var sign = dir === 'desc' ? -1 : 1;
|
|
288
|
+
function fxFor(r, cell) {
|
|
289
|
+
if (!fx || !cell || !cell.raw || cell.raw.charAt(0) !== '=') return null;
|
|
290
|
+
return (fx[r] && fx[r][col]) || null;
|
|
291
|
+
}
|
|
292
|
+
body.sort(function (ra, rb) {
|
|
293
|
+
var ca = model.cells[ra] && model.cells[ra][col];
|
|
294
|
+
var cb = model.cells[rb] && model.cells[rb][col];
|
|
295
|
+
var a = sortKey(ca, fxFor(ra, ca));
|
|
296
|
+
var b = sortKey(cb, fxFor(rb, cb));
|
|
297
|
+
if (a.rank === 2 || b.rank === 2) return a.rank - b.rank; // empty always last
|
|
298
|
+
if (a.rank !== b.rank) return (a.rank - b.rank) * sign;
|
|
299
|
+
if (a.v < b.v) return -1 * sign;
|
|
300
|
+
if (a.v > b.v) return 1 * sign;
|
|
301
|
+
return 0;
|
|
302
|
+
});
|
|
303
|
+
return head.concat(body).concat(tail);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Display formatting for a numeric raw string: group the integer part with
|
|
307
|
+
// thousands separators, preserve the sign and the decimal part verbatim
|
|
308
|
+
// (so "1234.50" keeps its trailing zero). Display only - the model's raw is
|
|
309
|
+
// untouched, so copy / export still emit the original value.
|
|
310
|
+
function formatNumber(raw) {
|
|
311
|
+
var s = String(raw == null ? '' : raw).trim();
|
|
312
|
+
var neg = s.charAt(0) === '-';
|
|
313
|
+
if (neg) s = s.slice(1);
|
|
314
|
+
var dot = s.indexOf('.');
|
|
315
|
+
var intPart = dot === -1 ? s : s.slice(0, dot);
|
|
316
|
+
var rest = dot === -1 ? '' : s.slice(dot);
|
|
317
|
+
intPart = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
318
|
+
return (neg ? '-' : '') + intPart + rest;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Parse one format token into a spec: { kind, symbol?, decimals? }.
|
|
322
|
+
// Tokens: $ / usd / currency, £ / gbp, € / eur, % / percent, , / comma /
|
|
323
|
+
// number, plain / text / raw, plus an optional trailing .N for decimals
|
|
324
|
+
// (e.g. "$.0", "%.1", ".2"). Unknown tokens return null.
|
|
325
|
+
function parseFmtToken(tok) {
|
|
326
|
+
var t = String(tok).trim();
|
|
327
|
+
var decimals = null;
|
|
328
|
+
var dm = t.match(/\.(\d+)$/);
|
|
329
|
+
if (dm) { decimals = parseInt(dm[1], 10); t = t.slice(0, t.length - dm[0].length); }
|
|
330
|
+
var lc = t.toLowerCase();
|
|
331
|
+
if (t === '$' || lc === 'usd' || lc === 'currency') return { kind: 'currency', symbol: '$', decimals: decimals == null ? 2 : decimals };
|
|
332
|
+
if (t === '£' || lc === 'gbp') return { kind: 'currency', symbol: '£', decimals: decimals == null ? 2 : decimals };
|
|
333
|
+
if (t === '€' || lc === 'eur') return { kind: 'currency', symbol: '€', decimals: decimals == null ? 2 : decimals };
|
|
334
|
+
if (t === '%' || lc === 'percent') { var p = { kind: 'percent' }; if (decimals != null) p.decimals = decimals; return p; }
|
|
335
|
+
if (t === ',' || lc === 'comma' || lc === 'number' || lc === 'num') { var nn = { kind: 'number' }; if (decimals != null) nn.decimals = decimals; return nn; }
|
|
336
|
+
if (lc === 'plain' || lc === 'text' || lc === 'raw') return { kind: 'plain' };
|
|
337
|
+
if (t === '' && decimals != null) return { kind: 'number', decimals: decimals };
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Parse a per-column format spec like "A=plain B=$ C=%.1" into a map
|
|
342
|
+
// { colIndex: fmt }. Keys are column letters; unknown tokens are skipped.
|
|
343
|
+
function parseFormats(spec) {
|
|
344
|
+
var out = {};
|
|
345
|
+
var re = /([A-Za-z]+)\s*=\s*(\S+)/g;
|
|
346
|
+
var m;
|
|
347
|
+
while ((m = re.exec(spec))) {
|
|
348
|
+
var col = colIndex(m[1]);
|
|
349
|
+
var fmt = parseFmtToken(m[2]);
|
|
350
|
+
if (col >= 0 && fmt) out[col] = fmt;
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Format a numeric cell's display per a column format. Returns null for
|
|
356
|
+
// non-number cells (the caller falls back to text rendering). Display only -
|
|
357
|
+
// the model's raw is untouched, so copy / export emit the original.
|
|
358
|
+
function formatValue(cell, fmt) {
|
|
359
|
+
if (!cell || cell.type !== 'number') return null;
|
|
360
|
+
var v = cell.value;
|
|
361
|
+
if (!fmt || fmt.kind === 'number') {
|
|
362
|
+
return (fmt && fmt.decimals != null) ? formatNumber(v.toFixed(fmt.decimals)) : formatNumber(cell.raw);
|
|
363
|
+
}
|
|
364
|
+
if (fmt.kind === 'plain') return cell.raw;
|
|
365
|
+
if (fmt.kind === 'currency') {
|
|
366
|
+
var d = fmt.decimals == null ? 2 : fmt.decimals;
|
|
367
|
+
return (v < 0 ? '-' : '') + fmt.symbol + formatNumber(Math.abs(v).toFixed(d));
|
|
368
|
+
}
|
|
369
|
+
if (fmt.kind === 'percent') {
|
|
370
|
+
var p = v * 100;
|
|
371
|
+
var str = fmt.decimals != null ? p.toFixed(fmt.decimals) : String(Math.round(p * 1e6) / 1e6);
|
|
372
|
+
return formatNumber(str) + '%';
|
|
373
|
+
}
|
|
374
|
+
return formatNumber(cell.raw);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
exports.colName = colName;
|
|
378
|
+
exports.classify = classify;
|
|
379
|
+
exports.parseCsv = parseCsv;
|
|
380
|
+
exports.parseCells = parseCells;
|
|
381
|
+
exports.serializeCsv = serializeCsv;
|
|
382
|
+
exports.selectionStats = selectionStats;
|
|
383
|
+
exports.formatNumber = formatNumber;
|
|
384
|
+
exports.colIndex = colIndex;
|
|
385
|
+
exports.parseFormats = parseFormats;
|
|
386
|
+
exports.formatValue = formatValue;
|
|
387
|
+
exports.looksLikeHeader = looksLikeHeader;
|
|
388
|
+
exports.sortRows = sortRows;
|
|
389
|
+
})(typeof module !== 'undefined' && module.exports ? module.exports : (window.SDocCells = {}));
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// sdocs-contrast.js - WCAG contrast analysis for custom-styled documents.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: an agent that hand-picks colours can easily produce an
|
|
4
|
+
// unreadable pair - dark text on a dark background, a navy heading on a near
|
|
5
|
+
// black page - without noticing, especially when it tuned the colours while
|
|
6
|
+
// viewing one theme. This module resolves the effective palette for BOTH the
|
|
7
|
+
// light and dark themes (mirroring how the browser applies front-matter
|
|
8
|
+
// styles) and grades every text-on-background pair against WCAG ratios, so
|
|
9
|
+
// `sdoc color-analysis` can warn before the document ships.
|
|
10
|
+
//
|
|
11
|
+
// Pure: no I/O, no third-party deps. Shared between the CLI and tests (and
|
|
12
|
+
// available to the browser via window.SDocContrast).
|
|
13
|
+
(function (exports) {
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
var SDocStyles = (typeof module !== 'undefined' && module.exports)
|
|
17
|
+
? require('./sdocs-styles.js')
|
|
18
|
+
: (typeof window !== 'undefined' ? window.SDocStyles : null);
|
|
19
|
+
|
|
20
|
+
// Light-theme defaults for colours the document didn't override. Mirrors
|
|
21
|
+
// the LIGHT_DEFAULTS / DARK_DEFAULTS tables in sdocs-theme.js. Headings
|
|
22
|
+
// default to the body text colour (the colour cascade root).
|
|
23
|
+
var LIGHT_DEFAULTS = {
|
|
24
|
+
bg: '#ffffff', text: '#1c1917', link: '#2563eb',
|
|
25
|
+
blockBg: '#f4f1ed', blockText: '#6b6560',
|
|
26
|
+
codeBg: '#f4f1ed', codeText: '#6b21a8',
|
|
27
|
+
bqBg: '#f7f5f2', bqText: '#6b6560'
|
|
28
|
+
};
|
|
29
|
+
var DARK_DEFAULTS = {
|
|
30
|
+
bg: '#2c2a26', text: '#e7e5e2', link: '#60a5fa',
|
|
31
|
+
blockBg: '#1a1816', blockText: '#a8a29e',
|
|
32
|
+
codeBg: '#1a1816', codeText: '#b8a99a',
|
|
33
|
+
bqBg: '#252320', bqText: '#a8a29e'
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// ── WCAG maths ────────────────────────────────────────
|
|
37
|
+
function hexToRgb(hex) {
|
|
38
|
+
if (typeof hex !== 'string') return null;
|
|
39
|
+
var h = hex.trim().replace(/^#/, '');
|
|
40
|
+
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
|
41
|
+
if (!/^[0-9a-fA-F]{6}$/.test(h)) return null;
|
|
42
|
+
return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function channelLin(c) {
|
|
46
|
+
var s = c / 255;
|
|
47
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function relativeLuminance(hex) {
|
|
51
|
+
var rgb = hexToRgb(hex);
|
|
52
|
+
if (!rgb) return null;
|
|
53
|
+
return 0.2126 * channelLin(rgb.r) + 0.7152 * channelLin(rgb.g) + 0.0722 * channelLin(rgb.b);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// WCAG contrast ratio, 1..21. Returns null for unparseable input.
|
|
57
|
+
function contrastRatio(a, b) {
|
|
58
|
+
var la = relativeLuminance(a), lb = relativeLuminance(b);
|
|
59
|
+
if (la == null || lb == null) return null;
|
|
60
|
+
var hi = Math.max(la, lb), lo = Math.min(la, lb);
|
|
61
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Fail line. Calibrated against human review rather than the strict
|
|
65
|
+
// WCAG-AA body bar (4.5:1): pairs in the ~3.2-4.4 range read fine on a
|
|
66
|
+
// normal screen, while everything that actually caused unreadable docs
|
|
67
|
+
// sat well below 3:1. Set as a single constant so it's easy to retune.
|
|
68
|
+
// (WCAG bands are still reported via `level` for anyone who wants them.)
|
|
69
|
+
var MIN_CONTRAST = 3.0;
|
|
70
|
+
|
|
71
|
+
// Grade a ratio against MIN_CONTRAST. `large` is kept for callers that
|
|
72
|
+
// want to annotate heading vs body, but the pass/fail line is uniform.
|
|
73
|
+
// level: 'fail' | 'aa-large' | 'aa' | 'aaa' (informational WCAG bands)
|
|
74
|
+
// ok: ratio meets the fail line
|
|
75
|
+
function grade(ratio, large) {
|
|
76
|
+
var level;
|
|
77
|
+
if (ratio == null) level = 'unknown';
|
|
78
|
+
else if (ratio >= 7) level = 'aaa';
|
|
79
|
+
else if (ratio >= 4.5) level = 'aa';
|
|
80
|
+
else if (ratio >= 3) level = 'aa-large';
|
|
81
|
+
else level = 'fail';
|
|
82
|
+
return {
|
|
83
|
+
ratio: ratio == null ? null : Math.round(ratio * 100) / 100,
|
|
84
|
+
level: level,
|
|
85
|
+
need: MIN_CONTRAST,
|
|
86
|
+
ok: ratio != null && ratio >= MIN_CONTRAST
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Palette resolution ────────────────────────────────
|
|
91
|
+
// Resolve a single colour for both themes. `explicit` is the front-matter
|
|
92
|
+
// value (or null/undefined). The dark value mirrors applyStylesFromMeta:
|
|
93
|
+
// an explicit dark override wins, else an explicit light value is inverted,
|
|
94
|
+
// else the theme default applies.
|
|
95
|
+
function resolve(explicit, ctrlId, darkBlock, lightDefault, darkDefault) {
|
|
96
|
+
var light = explicit || lightDefault;
|
|
97
|
+
var dark;
|
|
98
|
+
if (darkBlock && darkBlock[ctrlId]) dark = darkBlock[ctrlId];
|
|
99
|
+
else if (explicit && SDocStyles && SDocStyles.invertLightness) {
|
|
100
|
+
dark = SDocStyles.invertLightness(explicit, SDocStyles.colorControlRole
|
|
101
|
+
? SDocStyles.colorControlRole(ctrlId) : undefined);
|
|
102
|
+
} else dark = darkDefault;
|
|
103
|
+
return { light: light, dark: dark };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Resolve the full set of text-on-background pairs for a parsed `styles`
|
|
107
|
+
// object. Returns { light: [pairs], dark: [pairs] } where each pair is
|
|
108
|
+
// { label, surface, fg, bg, large }.
|
|
109
|
+
function resolvePairs(styles) {
|
|
110
|
+
styles = styles || {};
|
|
111
|
+
var darkBlock = (SDocStyles && SDocStyles.parseDarkBlock) ? SDocStyles.parseDarkBlock(styles.dark) : {};
|
|
112
|
+
var headers = styles.headers || {};
|
|
113
|
+
var h = function (n) { return styles['h' + n] || {}; };
|
|
114
|
+
|
|
115
|
+
// Page background and the colours that sit on it.
|
|
116
|
+
var bg = resolve(styles.background, '_sd_ctrl-bg-color', darkBlock, LIGHT_DEFAULTS.bg, DARK_DEFAULTS.bg);
|
|
117
|
+
var body = resolve(styles.color, '_sd_ctrl-color', darkBlock, LIGHT_DEFAULTS.text, DARK_DEFAULTS.text);
|
|
118
|
+
var headingFallback = headers.color || styles.color;
|
|
119
|
+
function heading(n) {
|
|
120
|
+
var explicit = h(n).color || headers.color;
|
|
121
|
+
var id = '_sd_ctrl-h' + n + '-color';
|
|
122
|
+
var lightDef = headingFallback || LIGHT_DEFAULTS.text;
|
|
123
|
+
var darkDef = body.dark;
|
|
124
|
+
return resolve(explicit, id, darkBlock, lightDef, darkDef);
|
|
125
|
+
}
|
|
126
|
+
var h1 = heading(1), h2 = heading(2), h3 = heading(3), h4 = heading(4);
|
|
127
|
+
var link = resolve((styles.link || {}).color, '_sd_ctrl-link-color', darkBlock, LIGHT_DEFAULTS.link, DARK_DEFAULTS.link);
|
|
128
|
+
|
|
129
|
+
var blocks = styles.blocks || {};
|
|
130
|
+
var bqBg = resolve((styles.blockquote || {}).background || blocks.background, '_sd_ctrl-bq-bg', darkBlock, LIGHT_DEFAULTS.bqBg, DARK_DEFAULTS.bqBg);
|
|
131
|
+
var bqText = resolve((styles.blockquote || {}).color || blocks.color, '_sd_ctrl-bq-color', darkBlock, LIGHT_DEFAULTS.bqText, DARK_DEFAULTS.bqText);
|
|
132
|
+
var codeBg = resolve((styles.code || {}).background || blocks.background, '_sd_ctrl-code-bg', darkBlock, LIGHT_DEFAULTS.codeBg, DARK_DEFAULTS.codeBg);
|
|
133
|
+
var codeText = resolve((styles.code || {}).color || blocks.color, '_sd_ctrl-code-color', darkBlock, LIGHT_DEFAULTS.codeText, DARK_DEFAULTS.codeText);
|
|
134
|
+
|
|
135
|
+
function build(theme) {
|
|
136
|
+
var pick = function (c) { return c[theme]; };
|
|
137
|
+
return [
|
|
138
|
+
{ label: 'body text', surface: 'page', fg: pick(body), bg: pick(bg), large: false },
|
|
139
|
+
{ label: 'h1', surface: 'page', fg: pick(h1), bg: pick(bg), large: true },
|
|
140
|
+
{ label: 'h2', surface: 'page', fg: pick(h2), bg: pick(bg), large: true },
|
|
141
|
+
{ label: 'h3', surface: 'page', fg: pick(h3), bg: pick(bg), large: true },
|
|
142
|
+
{ label: 'h4', surface: 'page', fg: pick(h4), bg: pick(bg), large: true },
|
|
143
|
+
{ label: 'link', surface: 'page', fg: pick(link), bg: pick(bg), large: false },
|
|
144
|
+
{ label: 'blockquote text', surface: 'blockquote', fg: pick(bqText), bg: pick(bqBg), large: false },
|
|
145
|
+
{ label: 'code text', surface: 'code block', fg: pick(codeText), bg: pick(codeBg), large: false }
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
return { light: build('light'), dark: build('dark') };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Full analysis for a parsed styles object: grades every pair in both
|
|
152
|
+
// themes. `hasCustomStyles` is false when the document set no colours, in
|
|
153
|
+
// which case the built-in defaults are known-good and nothing is flagged.
|
|
154
|
+
function analyzeStyles(styles) {
|
|
155
|
+
var hasColors = styles && hasCustomColors(styles);
|
|
156
|
+
var pairs = resolvePairs(styles);
|
|
157
|
+
function gradeList(list) {
|
|
158
|
+
return list.map(function (p) {
|
|
159
|
+
var g = grade(contrastRatio(p.fg, p.bg), p.large);
|
|
160
|
+
return {
|
|
161
|
+
label: p.label, surface: p.surface, fg: p.fg, bg: p.bg, large: p.large,
|
|
162
|
+
ratio: g.ratio, level: g.level, need: g.need, ok: g.ok
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
var light = gradeList(pairs.light);
|
|
167
|
+
var dark = gradeList(pairs.dark);
|
|
168
|
+
var fails = light.concat(dark).filter(function (p) { return !p.ok; });
|
|
169
|
+
return { hasCustomColors: !!hasColors, light: light, dark: dark, fails: fails };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function hasCustomColors(styles) {
|
|
173
|
+
if (!styles) return false;
|
|
174
|
+
var keys = ['background', 'color', 'link', 'blocks', 'blockquote', 'code', 'headers', 'h1', 'h2', 'h3', 'h4', 'dark'];
|
|
175
|
+
for (var i = 0; i < keys.length; i++) {
|
|
176
|
+
var v = styles[keys[i]];
|
|
177
|
+
if (v == null) continue;
|
|
178
|
+
if (typeof v === 'string') return true; // background / color
|
|
179
|
+
if (typeof v === 'object') {
|
|
180
|
+
if (v.color || v.background || v.borderColor) return true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
exports.hexToRgb = hexToRgb;
|
|
187
|
+
exports.relativeLuminance = relativeLuminance;
|
|
188
|
+
exports.contrastRatio = contrastRatio;
|
|
189
|
+
exports.grade = grade;
|
|
190
|
+
exports.MIN_CONTRAST = MIN_CONTRAST;
|
|
191
|
+
exports.resolvePairs = resolvePairs;
|
|
192
|
+
exports.analyzeStyles = analyzeStyles;
|
|
193
|
+
exports.hasCustomColors = hasCustomColors;
|
|
194
|
+
exports.LIGHT_DEFAULTS = LIGHT_DEFAULTS;
|
|
195
|
+
exports.DARK_DEFAULTS = DARK_DEFAULTS;
|
|
196
|
+
})(typeof module !== 'undefined' && module.exports ? module.exports : (window.SDocContrast = {}));
|