@ugfoundation/swaralipi-js 0.8.0 → 0.8.2

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.
@@ -116,6 +116,9 @@ interface Arc {
116
116
  /** 0-based index of the arc's first / last **cell** within the row. */
117
117
  start: number;
118
118
  end: number;
119
+ /** Index, within that cell's notes, of the note the arc hangs from / into. */
120
+ startNote: number;
121
+ endNote: number;
119
122
  /** True when the matching end lies on a previous / following system. */
120
123
  openLeft: boolean;
121
124
  openRight: boolean;
@@ -123,11 +126,14 @@ interface Arc {
123
126
  line?: boolean;
124
127
  }
125
128
  /**
126
- * Resolve under-arcs for one row, in **cell** indices (cells are the grid's
127
- * columns). Sources, scanned left→right:
128
- * - below-annotation raws: `w` opens at its cell, `x` closes at its cell
129
- * ("xw" in one cell = close-then-open, in the order typed);
130
- * - spec `meend: 'start' | 'end'` fields feed the same scan.
129
+ * Resolve under-arcs for one row down to the **note** each end hangs from, so a
130
+ * slur inside one half-matra cell (মজ্ঞা) still runs centre to centre. Sources,
131
+ * scanned left→right:
132
+ * - spec `meend: 'start' | 'end'` — exact notes;
133
+ * - below-annotation raws, which belong to the whole cell (NLTR types them
134
+ * centred under the column): `w` opens on the cell's first sounded note,
135
+ * `x` closes on its last, `$` (Swarabitan's short in-cell arc) runs first to
136
+ * last, and `xw` — close, then re-open — meets in the middle instead.
131
137
  * Unmatched `x` → arc from cell 0 with a squared-off (open) left end;
132
138
  * unclosed `w` → arc to the last cell with an open right end — both match
133
139
  * the print's behavior when an arc continues across a system wrap.
@@ -116,6 +116,9 @@ interface Arc {
116
116
  /** 0-based index of the arc's first / last **cell** within the row. */
117
117
  start: number;
118
118
  end: number;
119
+ /** Index, within that cell's notes, of the note the arc hangs from / into. */
120
+ startNote: number;
121
+ endNote: number;
119
122
  /** True when the matching end lies on a previous / following system. */
120
123
  openLeft: boolean;
121
124
  openRight: boolean;
@@ -123,11 +126,14 @@ interface Arc {
123
126
  line?: boolean;
124
127
  }
125
128
  /**
126
- * Resolve under-arcs for one row, in **cell** indices (cells are the grid's
127
- * columns). Sources, scanned left→right:
128
- * - below-annotation raws: `w` opens at its cell, `x` closes at its cell
129
- * ("xw" in one cell = close-then-open, in the order typed);
130
- * - spec `meend: 'start' | 'end'` fields feed the same scan.
129
+ * Resolve under-arcs for one row down to the **note** each end hangs from, so a
130
+ * slur inside one half-matra cell (মজ্ঞা) still runs centre to centre. Sources,
131
+ * scanned left→right:
132
+ * - spec `meend: 'start' | 'end'` — exact notes;
133
+ * - below-annotation raws, which belong to the whole cell (NLTR types them
134
+ * centred under the column): `w` opens on the cell's first sounded note,
135
+ * `x` closes on its last, `$` (Swarabitan's short in-cell arc) runs first to
136
+ * last, and `xw` — close, then re-open — meets in the middle instead.
131
137
  * Unmatched `x` → arc from cell 0 with a squared-off (open) left end;
132
138
  * unclosed `w` → arc to the last cell with an open right end — both match
133
139
  * the print's behavior when an arc continues across a system wrap.
@@ -194,42 +194,64 @@ function alignRows(rows) {
194
194
  }
195
195
  function resolveArcs(cells) {
196
196
  const arcs = [];
197
- let openStart;
197
+ let open;
198
198
  let sawEnds = false;
199
199
  const fillCells = [];
200
- const openAt = (i) => {
200
+ const openAt = (cell, note) => {
201
201
  sawEnds = true;
202
- if (openStart === void 0) openStart = i;
202
+ open ??= { cell, note };
203
203
  };
204
- const closeAt = (i) => {
204
+ const closeAt = (cell, note) => {
205
205
  sawEnds = true;
206
- if (openStart !== void 0) {
207
- arcs.push({ start: openStart, end: i, openLeft: false, openRight: false });
208
- openStart = void 0;
209
- } else {
210
- arcs.push({ start: 0, end: i, openLeft: true, openRight: false });
211
- }
206
+ const from = open ?? { cell: 0, note: 0 };
207
+ arcs.push({
208
+ start: from.cell,
209
+ startNote: from.note,
210
+ end: cell,
211
+ endNote: note,
212
+ openLeft: open === void 0,
213
+ openRight: false
214
+ });
215
+ open = void 0;
212
216
  };
213
217
  cells.forEach((cell, i) => {
214
- for (const note of cell.notes) {
215
- if (note.meend === "start") openAt(i);
216
- else if (note.meend === "end") closeAt(i);
217
- for (const b of note.below ?? []) {
218
- if (!isArcRaw(b.raw)) continue;
219
- for (const ch of b.raw) {
220
- if (ch === "w" || ch === "W") openAt(i);
221
- else if (ch === "x" || ch === "X") closeAt(i);
222
- else fillCells.push(i);
223
- }
224
- }
218
+ const pitched = cell.notes.flatMap((n, j) => isPitched(n) ? [j] : []);
219
+ const first = pitched[0] ?? 0;
220
+ const last = pitched[pitched.length - 1] ?? cell.notes.length - 1;
221
+ let marks = "";
222
+ cell.notes.forEach((note, j) => {
223
+ if (note.meend === "start") openAt(i, j);
224
+ else if (note.meend === "end") closeAt(i, j);
225
+ for (const b of note.below ?? []) if (isArcRaw(b.raw)) marks += b.raw;
226
+ });
227
+ const reopens = /[xX].*[wW]/.test(marks);
228
+ for (const ch of marks) {
229
+ if (ch === "w" || ch === "W") openAt(i, reopens ? last : first);
230
+ else if (ch === "x" || ch === "X") closeAt(i, reopens ? first : last);
231
+ else if (ch === "$") {
232
+ openAt(i, first);
233
+ closeAt(i, last);
234
+ } else fillCells.push(i);
225
235
  }
226
236
  });
227
- if (openStart !== void 0) {
228
- arcs.push({ start: openStart, end: Math.max(cells.length - 1, 0), openLeft: false, openRight: true });
237
+ const lastCell = Math.max(cells.length - 1, 0);
238
+ const lastNote = (c) => Math.max((cells[c]?.notes.length ?? 1) - 1, 0);
239
+ if (open !== void 0) {
240
+ arcs.push({
241
+ start: open.cell,
242
+ startNote: open.note,
243
+ end: lastCell,
244
+ endNote: lastNote(lastCell),
245
+ openLeft: false,
246
+ openRight: true
247
+ });
229
248
  } else if (!sawEnds && fillCells.length > 0) {
249
+ const end = Math.max(...fillCells);
230
250
  arcs.push({
231
251
  start: Math.min(...fillCells),
232
- end: Math.max(...fillCells),
252
+ startNote: 0,
253
+ end,
254
+ endNote: lastNote(end),
233
255
  openLeft: true,
234
256
  openRight: true,
235
257
  line: true
@@ -237,6 +259,35 @@ function resolveArcs(cells) {
237
259
  }
238
260
  return arcs;
239
261
  }
262
+ function arcInk(cells, arcs) {
263
+ const ink = { cells: cells.map(() => ""), notes: cells.map((c) => c.notes.map(() => "")) };
264
+ const mark = (c, j, cls, clip = false) => {
265
+ ink.notes[c][j] += ` ${cls}`;
266
+ if (clip && !ink.cells[c].includes(" sw-mk")) ink.cells[c] += " sw-mk";
267
+ };
268
+ for (const arc of arcs) {
269
+ const { start: s, end: e, startNote: j, endNote: k } = arc;
270
+ const hookL = arc.line !== true && !arc.openLeft;
271
+ const hookR = arc.line !== true && !arc.openRight;
272
+ if (s === e && hookL && hookR) {
273
+ if (j === k) {
274
+ mark(s, j, "sw-mos");
275
+ mark(s, j, "sw-mcs");
276
+ } else {
277
+ mark(s, j, "sw-mob");
278
+ for (let n = j + 1; n < k; n++) mark(s, n, "sw-mf");
279
+ mark(s, k, "sw-mcb");
280
+ }
281
+ continue;
282
+ }
283
+ for (let c = s; c <= e; c++) {
284
+ if (c === s && hookL) mark(c, j, "sw-mo", true);
285
+ else if (c === e && hookR) mark(c, k, "sw-mc", true);
286
+ else mark(c, 0, "sw-mm", true);
287
+ }
288
+ }
289
+ return ink;
290
+ }
240
291
  var SVARA_CHAR = /[srgmpqndtkuv]/;
241
292
  function rawMarks(raw, count) {
242
293
  if (raw === void 0) return void 0;
@@ -412,21 +463,9 @@ function renderNote(note, kind, opts, ctx) {
412
463
  const raw = rawDisplay(note);
413
464
  return bracketGlyphs(note, "open") + (raw !== void 0 ? `<span class="sw-raw" title="${escapeHtml(raw)}">${escapeHtml(raw)}</span>` : "") + bracketGlyphs(note, "close");
414
465
  }
415
- function renderArc(arc, offset) {
416
- const open = arc.line === true;
417
- const cls = [
418
- "sw-arc",
419
- arc.openLeft || open ? "sw-arc--openl" : "",
420
- arc.openRight || open ? "sw-arc--openr" : ""
421
- ].filter(Boolean).join(" ");
422
- const left = open || arc.openLeft ? "" : '<i class="sw-arc-l">w</i>';
423
- const right = open || arc.openRight ? "" : '<i class="sw-arc-r">x</i>';
424
- const from = offset + arc.start + COL_BASE;
425
- const to = offset + arc.end + COL_BASE + 1;
426
- return `<span class="${cls}" style="grid-column:${from} / ${to}">${left}<i class="sw-arc-m"></i>${right}</span>`;
427
- }
428
466
  function renderRow(row, partIdx, rowIdx, offset, ctx) {
429
467
  const cells = groupRowIntoCells(row.notes);
468
+ const ink = arcInk(cells, resolveArcs(cells));
430
469
  const out = [];
431
470
  if (row.label !== void 0)
432
471
  out.push(`<span class="sw-label">${escapeHtml(ctx.tr(row.label))}</span>`);
@@ -436,11 +475,10 @@ function renderRow(row, partIdx, rowIdx, offset, ctx) {
436
475
  const barCls = cell.kind === "bar" ? " sw-bar-cell" : "";
437
476
  const notes = cell.notes.map((note, j) => {
438
477
  const path = `${partIdx}.${rowIdx}.${cell.start + j}`;
439
- const cls = isPitched(note) ? " sw-p" : "";
440
478
  const bar = note.bar !== void 0 ? ` sw-bar sw-bar--${note.bar}` : "";
441
- return `<span class="sw-n${cls}${bar}" data-note="${path}">${renderNote(note, cell.kind, opts[j], ctx)}</span>`;
479
+ return `<span class="sw-n${bar}${ink.notes[ci][j]}" data-note="${path}">${renderNote(note, cell.kind, opts[j], ctx)}</span>`;
442
480
  }).join("");
443
- out.push(`<span class="sw-cell${barCls}" style="grid-column:${col}">${notes}</span>`);
481
+ out.push(`<span class="sw-cell${barCls}${ink.cells[ci]}" style="grid-column:${col}">${notes}</span>`);
444
482
  const slices = cellLyricSlices(cell);
445
483
  if (slices !== void 0) {
446
484
  const html = slices.map((t) => `<span>${escapeHtml(lyricText(t, ctx))}</span>`).join("");
@@ -453,7 +491,6 @@ function renderRow(row, partIdx, rowIdx, offset, ctx) {
453
491
  if (below.length > 0)
454
492
  out.push(`<span class="sw-below" style="grid-column:${col}">${renderAnnotation(below, ctx)}</span>`);
455
493
  });
456
- for (const arc of resolveArcs(cells)) out.push(renderArc(arc, offset));
457
494
  return `<div class="sw-row">${out.join("")}</div>`;
458
495
  }
459
496
  function renderHeader(meta, ctx) {
@@ -511,7 +548,7 @@ function fitSwaralipi(root, minScale = 0, zoom = 1, minSpacing = 0.35, minSize =
511
548
  sheet.style.removeProperty("--sw-size");
512
549
  sheet.style.removeProperty("--sw-tight");
513
550
  const available = scroll.clientWidth;
514
- let natural = block.scrollWidth;
551
+ let natural = block.offsetWidth;
515
552
  if (available === 0 || natural === 0) continue;
516
553
  if (natural > available && minSpacing < 1) {
517
554
  let loose = minSpacing;
@@ -519,11 +556,11 @@ function fitSwaralipi(root, minScale = 0, zoom = 1, minSpacing = 0.35, minSize =
519
556
  for (let i = 0; i < 6; i++) {
520
557
  const mid = (loose + tight) / 2;
521
558
  sheet.style.setProperty("--sw-tight", String(mid));
522
- if (block.scrollWidth <= available) loose = mid;
559
+ if (block.offsetWidth <= available) loose = mid;
523
560
  else tight = mid;
524
561
  }
525
562
  sheet.style.setProperty("--sw-tight", String(loose));
526
- natural = block.scrollWidth;
563
+ natural = block.offsetWidth;
527
564
  }
528
565
  const base = parseFloat(getComputedStyle(sheet).fontSize);
529
566
  if (!(base > 0)) continue;
@@ -621,31 +658,45 @@ ${FONT_FACES}
621
658
  column-gap: calc(var(--sw-cell-gap, 0.4em) * var(--sw-tight, 1));
622
659
  align-items: end;
623
660
  width: max-content;
661
+ /* The last column's cell spacers overhang half a gap past the grid; clip
662
+ that sideways so it neither counts as width to fit nor raises a scrollbar. */
663
+ overflow-x: clip;
624
664
  }
625
665
  .sw-part { display: contents; }
666
+ /* Four FIXED bands per row \u2014 above / notation / arc / lyric \u2014 so a beat label,
667
+ * a \u0995\u09A3 or a \u09AE\u09C0\u09A1\u09BC never changes how far a line sits from its words: the
668
+ * notation hugs its lyric and the next system starts a little further off,
669
+ * as the printed \u09B8\u09CD\u09AC\u09B0\u09AC\u09BF\u09A4\u09BE\u09A8 sets it. Content taller than its band overflows
670
+ * rather than pushing the rhythm apart. */
626
671
  .sw-row {
627
672
  grid-column: 1 / -1;
628
673
  display: grid;
629
674
  grid-template-columns: subgrid;
630
- grid-template-rows: auto auto auto auto;
675
+ grid-template-rows: 0.95em 1.35em 0.4em 1.35em;
631
676
  align-items: end;
632
- padding: 0.4em 0;
677
+ padding-bottom: 0.35em;
678
+ line-height: 1.35;
633
679
  }
634
680
  .sw-label { grid-column: 1; grid-row: 2; padding-right: 0.5em; font-weight: 600; }
635
681
 
636
- /* One column per cell; sub-notes are equal slices of it (min-width:0 kills
637
- * flex's min-content floor, else a wide sub-note steals width from its
638
- * neighbours while the syllable below it does not, and the two bands drift). */
639
- .sw-cell, .sw-lyr { display: flex; justify-content: center; }
640
- .sw-cell > .sw-n { white-space: nowrap; }
641
- .sw-cell > .sw-n.sw-p,
642
- .sw-lyr > span { flex: 1 1 0; min-width: 0; text-align: center; white-space: nowrap; }
643
- .sw-cell { grid-row: 2; }
644
- .sw-above { grid-row: 1; font-size: 0.62em; justify-self: center; white-space: nowrap; }
645
- .sw-arc,
646
- .sw-below { grid-row: 3; }
647
- .sw-lyr { grid-row: 4; padding-top: 0.3em; }
648
- .sw-below { font-size: 0.62em; justify-self: center; white-space: nowrap; }
682
+ /* One column per cell, its notes set tight as one word (\u09B0\u09B8\u09BE) and centred by
683
+ * two flex spacers. Each spacer overhangs half the column gap (a min-width
684
+ * paid back by a negative margin, so the column is sized by the notes alone),
685
+ * which lets neighbouring cells' spacers meet mid-gap: a \u09AE\u09C0\u09A1\u09BC crossing cells is
686
+ * inked by these boxes with no hole between them. */
687
+ .sw-cell, .sw-lyr { display: flex; justify-content: center; white-space: nowrap; }
688
+ .sw-cell {
689
+ --sw-half-gap: calc(var(--sw-cell-gap, 0.4em) * var(--sw-tight, 1) / 2);
690
+ grid-row: 2;
691
+ align-self: stretch;
692
+ }
693
+ .sw-cell::before, .sw-cell::after { content: ''; flex: 1 0 0; min-width: var(--sw-half-gap); }
694
+ .sw-cell::before { margin-left: calc(var(--sw-half-gap) * -1); }
695
+ .sw-cell::after { margin-right: calc(var(--sw-half-gap) * -1); }
696
+ .sw-cell > .sw-n { position: relative; }
697
+ .sw-above { grid-row: 1; font-size: 0.62em; line-height: 1; justify-self: center; white-space: nowrap; }
698
+ .sw-below { grid-row: 3; font-size: 0.62em; line-height: 1; justify-self: center; white-space: nowrap; }
699
+ .sw-lyr { grid-row: 4; }
649
700
 
650
701
  /* ---- notation clusters & marks ---- */
651
702
  .sw-cl { position: relative; display: inline-block; }
@@ -666,12 +717,16 @@ ${FONT_FACES}
666
717
  .sw-ati-taar::before { top: -0.3em; }
667
718
  .sw-ati-mandra::before { bottom: -0.35em; }
668
719
 
669
- /* grace notes: small raised clusters \u2014 \u0995\u09A3 before the note, \u09B0\u09C7\u09B6 after it */
720
+ /* grace notes: small raised clusters \u2014 \u0995\u09A3 before the note, \u09B0\u09C7\u09B6 after it.
721
+ * line-height 0 (here and on .sw-micro): a raised inline box taller than the
722
+ * strut grows its line box and drops that one note's baseline below its
723
+ * neighbours' (css2 \xA710.8). */
670
724
  .sw-kan,
671
725
  .sw-resh {
672
726
  position: relative;
673
727
  display: inline-block;
674
728
  font-size: 0.58em;
729
+ line-height: 0;
675
730
  vertical-align: 0.75em;
676
731
  }
677
732
  .sw-kan { margin-right: 0.08em; }
@@ -703,7 +758,7 @@ ${FONT_FACES}
703
758
  .sw-cut::before { top: 0.2em; }
704
759
  .sw-cut::after { top: 0.55em; }
705
760
  .sw-br { opacity: 0.8; padding: 0 0.06em; }
706
- .sw-micro { font-size: 0.6em; vertical-align: super; opacity: 0.8; }
761
+ .sw-micro { font-size: 0.6em; line-height: 0; vertical-align: super; opacity: 0.8; }
707
762
 
708
763
  /* raw fallback: never dropped, visibly non-notation. Swarabitan first \u2014
709
764
  * raw IS that font's encoding, so undecoded cells show NLTR's own glyphs
@@ -723,32 +778,46 @@ ${FONT_FACES}
723
778
  /* a bar owns a whole column but needs no matra of width */
724
779
  .sw-bar-cell { min-width: 0; }
725
780
 
726
- /* ---- \u09AE\u09C0\u09A1\u09BC under-arcs (from w/x segments and spec meend) ----
727
- * Swarabitan draws the arc as three pieces \u2014 'w' the opening hook, 'y' a
728
- * flat fill, 'x' the closing hook. We use the font's own hooks and stretch
729
- * the fill as a CSS bar (the 'y' glyph IS a plain rectangle), so one arc
730
- * spans any number of columns. An open side = the arc continues onto the
731
- * next system: drop that hook and leave the fill squared off. */
732
- .sw-arc {
733
- display: flex;
734
- align-items: baseline;
735
- line-height: 1;
736
- font-family: Swarabitan, var(--sw-font, sans-serif);
781
+ /* ---- \u09AE\u09C0\u09A1\u09BC under-arcs (from w/x/$ segments and spec meend) ----
782
+ * Swarabitan's own arc, all langs: w is the opening hook, curling down from
783
+ * its tip into a flat, x its mirror, y that flat alone \u2014 and they join
784
+ * hole-free, so one run of text is a whole end (tools/align-check/glyph.html).
785
+ * Each end sets its hook tip on its note's centre and runs the flat toward the
786
+ * other end (render.ts arcInk): across cells it is cut at the mid-gap, where
787
+ * the next cell's run carries on (.sw-mk clips a cell half a gap past each
788
+ * side, as far as its spacers reach); inside one cell, at its note's edge. So
789
+ * a long arc extends the flat and a short one trims it off. line-height 0 sets
790
+ * every run's baseline the same distance below its note. The clip reaches
791
+ * 0.5px further so two cells' anti-aliased edges overlap instead of leaving a
792
+ * hairline seam between their identical flats. */
793
+ .sw-mk { clip-path: inset(-3em calc(var(--sw-half-gap) * -1 - 0.5px)); }
794
+ .sw-mo::after, .sw-mob::after, .sw-mos::after,
795
+ .sw-mc::before, .sw-mcb::before, .sw-mcs::before,
796
+ .sw-mf::before, .sw-mm::before {
797
+ content: 'yyyyyyyyyyyy';
798
+ position: absolute;
799
+ top: calc(100% + 0.05em);
800
+ font-family: Swarabitan;
737
801
  font-size: 0.9em;
738
- }
739
- .sw-arc-l, .sw-arc-r { flex: none; font-style: normal; }
740
- /* The fill's geometry is measured off the font's own 'y' glyph: ink 0.11em
741
- tall whose bottom sits 0.01em above the baseline. An empty inline-block
742
- takes its bottom margin edge as its baseline, so -0.01em lifts the bar to
743
- exactly where the hooks' flat stroke runs \u2014 no seam at either join. */
744
- .sw-arc-m {
745
- flex: 1 1 0;
746
- min-width: 0;
747
- align-self: baseline;
748
- height: 0.11em;
749
- margin-bottom: -0.01em;
750
- background: currentColor;
751
- }
802
+ line-height: 0;
803
+ white-space: pre;
804
+ overflow-x: clip;
805
+ }
806
+ .sw-mo::after, .sw-mob::after, .sw-mos::after { content: 'wyyyyyyyyyyyy'; }
807
+ /* rtl: a closing run cut short keeps its hook on its right edge */
808
+ .sw-mc::before, .sw-mcb::before, .sw-mcs::before { content: 'yyyyyyyyyyyyx'; direction: rtl; }
809
+ .sw-mo::after, .sw-mob::after { left: 50%; }
810
+ .sw-mc::before, .sw-mcb::before { right: 50%; }
811
+ .sw-mos::after { left: 0; }
812
+ .sw-mcs::before { right: 0; }
813
+ .sw-mf::before { left: 0; right: 0; }
814
+ .sw-mm::before { left: -12em; }
815
+ /* Inside one cell a hook gets half a note to curl in, about a third of its
816
+ * curl: squeezed to half width (same stroke) it lands flat at the cut, so the
817
+ * two flats meet level whatever the notes' widths \u2014 a small arc, not a V. */
818
+ .sw-mob::after, .sw-mos::after, .sw-mcb::before, .sw-mcs::before { width: 100%; transform: scaleX(0.5); }
819
+ .sw-mob::after, .sw-mos::after { transform-origin: left; }
820
+ .sw-mcb::before, .sw-mcs::before { transform-origin: right; }
752
821
 
753
822
  /* ---- annotation content ---- */
754
823
  .sw-beat { position: relative; display: inline-block; padding: 0 0.1em; }