sdocs-dev 1.14.1 → 1.18.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.
@@ -140,11 +140,11 @@
140
140
 
141
141
  // Peel leading directive lines (in any order, machine or author):
142
142
  // sdoc-cells: source=... range=... error=... name=... (baked metadata)
143
- // format: A=$ B=% C=plain (author column formats)
143
+ // format: A=$ 2=% C3=plain (column, row, cell formats)
144
144
  // `name` is the tab name. Authored as the fence info string (```cells
145
145
  // Sales); the renderer + CLI normalise that into this directive so the
146
146
  // name has one home in the model.
147
- var source, range, formats, name, workbook;
147
+ var source, range, formats, rowFormats, cellFormats, defaultFormat, name, workbook;
148
148
  var lines = trimmed.split('\n');
149
149
  var idx = 0;
150
150
  var FORMAT_RE = /^format:\s*(.*)$/i;
@@ -159,15 +159,22 @@
159
159
  if (meta.error) return { empty: false, error: meta.error, source: source, name: name, workbook: workbook };
160
160
  idx++; continue;
161
161
  }
162
- if (fm) { formats = parseFormats(fm[1]); idx++; continue; }
162
+ if (fm) {
163
+ var rules = parseFormatRules(fm[1]);
164
+ formats = mergeFormats(formats, rules.columns);
165
+ rowFormats = mergeFormats(rowFormats, rules.rows);
166
+ cellFormats = mergeFormats(cellFormats, rules.cells);
167
+ if (rules.defaultFormat) defaultFormat = rules.defaultFormat;
168
+ idx++; continue;
169
+ }
163
170
  break;
164
171
  }
165
172
  var body = lines.slice(idx).join('\n').replace(/\s+$/, '');
166
173
 
167
174
  // Unresolved reference (after directives): the CLI never baked it.
168
175
  var ref = body.match(REFERENCE_RE);
169
- if (ref) return { empty: false, unresolved: ref[1], formats: formats, name: name, workbook: workbook };
170
- if (body === '') return { rows: 0, cols: 0, cells: [], empty: true, source: source, range: range, formats: formats, name: name, workbook: workbook };
176
+ if (ref) return { empty: false, unresolved: ref[1], formats: formats, rowFormats: rowFormats, cellFormats: cellFormats, defaultFormat: defaultFormat, name: name, workbook: workbook };
177
+ if (body === '') return { rows: 0, cols: 0, cells: [], empty: true, source: source, range: range, formats: formats, rowFormats: rowFormats, cellFormats: cellFormats, defaultFormat: defaultFormat, name: name, workbook: workbook };
171
178
 
172
179
  var raw = parseCsv(body);
173
180
  var cols = 0;
@@ -183,7 +190,7 @@
183
190
  }
184
191
  cells.push(out);
185
192
  }
186
- return { rows: raw.length, cols: cols, cells: cells, empty: false, source: source, range: range, formats: formats, name: name, workbook: workbook };
193
+ return { rows: raw.length, cols: cols, cells: cells, empty: false, source: source, range: range, formats: formats, rowFormats: rowFormats, cellFormats: cellFormats, defaultFormat: defaultFormat, name: name, workbook: workbook };
187
194
  }
188
195
 
189
196
  // Serialize a 2D array of raw cell strings back to CSV (RFC 4180 quoting:
@@ -261,6 +268,9 @@
261
268
  function sortKey(cell, fxCell) {
262
269
  if (fxCell && fxCell.kind === 'number') return { rank: 0, v: fxCell.value };
263
270
  if (fxCell && fxCell.kind === 'error') return { rank: 1, v: String(fxCell.code || '').toLowerCase() };
271
+ if (fxCell && (fxCell.kind === 'text' || fxCell.kind === 'boolean')) {
272
+ return { rank: 1, v: String(fxCell.value).toLowerCase() };
273
+ }
264
274
  if (!cell || cell.type === 'empty') return { rank: 2, v: 0 };
265
275
  if (cell.type === 'number') return { rank: 0, v: cell.value };
266
276
  return { rank: 1, v: String(cell.value).toLowerCase() };
@@ -341,7 +351,10 @@
341
351
  var t = String(tok).trim();
342
352
  var decimals = null;
343
353
  var dm = t.match(/\.(\d+)$/);
344
- if (dm) { decimals = parseInt(dm[1], 10); t = t.slice(0, t.length - dm[0].length); }
354
+ if (dm) {
355
+ decimals = Math.min(30, parseInt(dm[1], 10));
356
+ t = t.slice(0, t.length - dm[0].length);
357
+ }
345
358
  var lc = t.toLowerCase();
346
359
  if (t === '$' || lc === 'usd' || lc === 'currency') return { kind: 'currency', symbol: '$', decimals: decimals == null ? 2 : decimals };
347
360
  if (t === '£' || lc === 'gbp') return { kind: 'currency', symbol: '£', decimals: decimals == null ? 2 : decimals };
@@ -356,15 +369,138 @@
356
369
  // Parse a per-column format spec like "A=plain B=$ C=%.1" into a map
357
370
  // { colIndex: fmt }. Keys are column letters; unknown tokens are skipped.
358
371
  function parseFormats(spec) {
372
+ return parseFormatRules(spec).columns;
373
+ }
374
+
375
+ function mergeFormats(base, extra) {
376
+ var out = base || {};
377
+ Object.keys(extra || {}).forEach(function (key) { out[key] = extra[key]; });
378
+ return out;
379
+ }
380
+
381
+ // Parse one compact format directive. Targets mirror spreadsheet addresses:
382
+ // *=.2 whole sheet
383
+ // B=$ column B
384
+ // 4=% row 4
385
+ // C7=plain cell C7
386
+ // Specific targets override broader ones when resolveFormat() is called.
387
+ function parseFormatRules(spec) {
359
388
  var out = {};
360
- var re = /([A-Za-z]+)\s*=\s*(\S+)/g;
389
+ var rows = {};
390
+ var cells = {};
391
+ var defaultFormat = null;
392
+ // Both sides must end at whitespace boundaries. Without these anchors a
393
+ // typo such as A-2=% could be recovered as the unrelated valid rule 2=%.
394
+ var re = /(^|\s)(\*|[A-Za-z]+[1-9]\d*|[A-Za-z]+|[1-9]\d*)\s*=\s*(\S+)(?=\s|$)/g;
361
395
  var m;
362
- while ((m = re.exec(spec))) {
363
- var col = colIndex(m[1]);
364
- var fmt = parseFmtToken(m[2]);
365
- if (col >= 0 && fmt) out[col] = fmt;
396
+ while ((m = re.exec(String(spec)))) {
397
+ var target = m[2];
398
+ var fmt = parseFmtToken(m[3]);
399
+ if (!fmt) continue;
400
+ if (target === '*') { defaultFormat = fmt; continue; }
401
+ if (/^[1-9]\d*$/.test(target)) { rows[parseInt(target, 10) - 1] = fmt; continue; }
402
+ var cell = target.match(/^([A-Za-z]+)([1-9]\d*)$/);
403
+ if (cell) {
404
+ var cellCol = colIndex(cell[1]);
405
+ if (cellCol >= 0) cells[(parseInt(cell[2], 10) - 1) + ':' + cellCol] = fmt;
406
+ continue;
407
+ }
408
+ var col = colIndex(target);
409
+ if (col >= 0) out[col] = fmt;
366
410
  }
367
- return out;
411
+ return { columns: out, rows: rows, cells: cells, defaultFormat: defaultFormat };
412
+ }
413
+
414
+ // Resolve display/export formatting with predictable spreadsheet precedence:
415
+ // built-in two-decimal maximum < whole sheet < column < row < cell.
416
+ function resolveFormat(model, row, col) {
417
+ var fmt = { kind: 'number', decimals: 2, trim: true };
418
+ if (model && model.defaultFormat) fmt = model.defaultFormat;
419
+ if (model && model.formats && model.formats[col]) fmt = model.formats[col];
420
+ if (model && model.rowFormats && model.rowFormats[row]) fmt = model.rowFormats[row];
421
+ if (model && model.cellFormats && model.cellFormats[row + ':' + col]) fmt = model.cellFormats[row + ':' + col];
422
+ return fmt;
423
+ }
424
+
425
+ // Parse a decimal string without passing through binary floating point.
426
+ // Formula values may arrive in exponent form; literal cells never do.
427
+ function decimalParts(raw) {
428
+ var s = String(raw == null ? '' : raw).trim();
429
+ var negative = s.charAt(0) === '-';
430
+ if (negative || s.charAt(0) === '+') s = s.slice(1);
431
+ var m = s.match(/^(\d+)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/);
432
+ if (!m) return null;
433
+ var integer = m[1];
434
+ var fraction = m[2] || '';
435
+ var exponent = m[3] ? parseInt(m[3], 10) : 0;
436
+ if (exponent) {
437
+ var digits = integer + fraction;
438
+ var point = integer.length + exponent;
439
+ if (point <= 0) {
440
+ integer = '0';
441
+ fraction = new Array(-point + 1).join('0') + digits;
442
+ } else if (point >= digits.length) {
443
+ integer = digits + new Array(point - digits.length + 1).join('0');
444
+ fraction = '';
445
+ } else {
446
+ integer = digits.slice(0, point);
447
+ fraction = digits.slice(point);
448
+ }
449
+ }
450
+ integer = integer.replace(/^0+(?=\d)/, '');
451
+ return { negative: negative, integer: integer || '0', fraction: fraction };
452
+ }
453
+
454
+ function incrementDigits(digits) {
455
+ var chars = digits.split('');
456
+ for (var i = chars.length - 1; i >= 0; i--) {
457
+ if (chars[i] !== '9') {
458
+ chars[i] = String.fromCharCode(chars[i].charCodeAt(0) + 1);
459
+ return chars.join('');
460
+ }
461
+ chars[i] = '0';
462
+ }
463
+ return '1' + chars.join('');
464
+ }
465
+
466
+ // Round half away from zero, matching spreadsheet display semantics. The
467
+ // returned string is exact even for integers beyond Number.MAX_SAFE_INTEGER.
468
+ function roundDecimalString(raw, decimals, trim) {
469
+ var parts = decimalParts(raw);
470
+ if (!parts) return null;
471
+ var d = Math.max(0, Math.min(30, decimals));
472
+ var fraction = parts.fraction.slice(0, d);
473
+ while (fraction.length < d) fraction += '0';
474
+ var digits = parts.integer + fraction;
475
+ if ((parts.fraction.charAt(d) || '0') >= '5') digits = incrementDigits(digits);
476
+ if (digits.length <= d) digits = new Array(d - digits.length + 2).join('0') + digits;
477
+ var split = digits.length - d;
478
+ var integer = d ? digits.slice(0, split) : digits;
479
+ fraction = d ? digits.slice(split) : '';
480
+ integer = integer.replace(/^0+(?=\d)/, '') || '0';
481
+ if (trim) fraction = fraction.replace(/0+$/, '');
482
+ var zero = /^0+$/.test(integer) && (!fraction || /^0+$/.test(fraction));
483
+ return (parts.negative && !zero ? '-' : '') + integer + (fraction ? '.' + fraction : '');
484
+ }
485
+
486
+ function shiftDecimalString(raw, places) {
487
+ var parts = decimalParts(raw);
488
+ if (!parts) return null;
489
+ var digits = parts.integer + parts.fraction;
490
+ var point = parts.integer.length + places;
491
+ var out;
492
+ if (point <= 0) out = '0.' + new Array(-point + 1).join('0') + digits;
493
+ else if (point >= digits.length) out = digits + new Array(point - digits.length + 1).join('0');
494
+ else out = digits.slice(0, point) + '.' + digits.slice(point);
495
+ return (parts.negative ? '-' : '') + out;
496
+ }
497
+
498
+ // Excel calculates to 15 significant decimal digits. Formula results arrive
499
+ // here as IEEE Numbers, whose shortest JS string can expose a 16th or 17th
500
+ // binary-conversion digit when a user requests a high-precision format.
501
+ function displayRaw(cell) {
502
+ if (!cell.computed || !isFinite(cell.value) || cell.value === 0) return cell.raw;
503
+ return Number(cell.value).toPrecision(15);
368
504
  }
369
505
 
370
506
  // Format a numeric cell's display per a column format. Returns null for
@@ -372,18 +508,26 @@
372
508
  // the model's raw is untouched, so copy / export emit the original.
373
509
  function formatValue(cell, fmt) {
374
510
  if (!cell || cell.type !== 'number') return null;
375
- var v = cell.value;
511
+ var raw = displayRaw(cell);
376
512
  if (!fmt || fmt.kind === 'number') {
377
- return (fmt && fmt.decimals != null) ? formatNumber(v.toFixed(fmt.decimals)) : formatNumber(cell.raw);
513
+ var decimals = !fmt ? 2 : fmt.decimals;
514
+ var numberText = decimals != null
515
+ ? roundDecimalString(raw, decimals, !fmt || fmt.trim)
516
+ : raw;
517
+ return formatNumber(numberText);
378
518
  }
379
519
  if (fmt.kind === 'plain') return cell.raw;
380
520
  if (fmt.kind === 'currency') {
381
521
  var d = fmt.decimals == null ? 2 : fmt.decimals;
382
- return (v < 0 ? '-' : '') + fmt.symbol + formatNumber(Math.abs(v).toFixed(d));
522
+ var money = roundDecimalString(raw, d, false);
523
+ var moneyNegative = money && money.charAt(0) === '-';
524
+ if (moneyNegative) money = money.slice(1);
525
+ return (moneyNegative ? '-' : '') + fmt.symbol + formatNumber(money);
383
526
  }
384
527
  if (fmt.kind === 'percent') {
385
- var p = v * 100;
386
- var str = fmt.decimals != null ? p.toFixed(fmt.decimals) : String(Math.round(p * 1e6) / 1e6);
528
+ var shifted = shiftDecimalString(raw, 2);
529
+ var str = roundDecimalString(shifted, fmt.decimals == null ? 2 : fmt.decimals,
530
+ fmt.decimals == null);
387
531
  return formatNumber(str) + '%';
388
532
  }
389
533
  return formatNumber(cell.raw);
@@ -399,6 +543,8 @@
399
543
  exports.formatNumber = formatNumber;
400
544
  exports.colIndex = colIndex;
401
545
  exports.parseFormats = parseFormats;
546
+ exports.parseFormatRules = parseFormatRules;
547
+ exports.resolveFormat = resolveFormat;
402
548
  exports.formatValue = formatValue;
403
549
  exports.looksLikeHeader = looksLikeHeader;
404
550
  exports.sortRows = sortRows;