@ugfoundation/swaralipi-js 0.6.1 → 0.7.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.
@@ -185,7 +185,7 @@ var init_spec = __esm({
185
185
  init_schema();
186
186
  init_schema();
187
187
  init_taals();
188
- SPEC_VERSION = "0.6.1";
188
+ SPEC_VERSION = "0.7.0";
189
189
  }
190
190
  });
191
191
 
@@ -460,49 +460,120 @@ function groupRowIntoCells(notes) {
460
460
  if (note.halfMatra === true || acc > EPS && isIntegral(acc)) close();
461
461
  });
462
462
  close();
463
- return cells;
463
+ return mergeStructural(absorbBrackets(cells));
464
+ }
465
+ var isStructural = (c) => c.kind === "bar" || c.kind === "carrier";
466
+ function bracketEdges(cell) {
467
+ if (cell.kind !== "carrier") return void 0;
468
+ let open = false;
469
+ let close = false;
470
+ for (const note of cell.notes) {
471
+ if (note.raw !== void 0 && !/^[{}()[\]\s]*$/.test(note.raw)) return void 0;
472
+ for (const b of note.brackets ?? []) {
473
+ if (b.edge === "open") open = true;
474
+ else close = true;
475
+ }
476
+ }
477
+ return open || close ? { open, close } : void 0;
478
+ }
479
+ function absorbBrackets(cells) {
480
+ const out = [];
481
+ for (let i = 0; i < cells.length; i++) {
482
+ const cell = cells[i];
483
+ const edges = bracketEdges(cell);
484
+ if (edges !== void 0 && edges.open !== edges.close) {
485
+ const prev = out[out.length - 1];
486
+ const next = cells[i + 1];
487
+ if (edges.close && prev?.kind === "group") {
488
+ prev.notes = [...prev.notes, ...cell.notes];
489
+ continue;
490
+ }
491
+ if (edges.open && next?.kind === "group") {
492
+ next.notes = [...cell.notes, ...next.notes];
493
+ next.start = cell.start;
494
+ continue;
495
+ }
496
+ }
497
+ out.push(cell);
498
+ }
499
+ return out;
500
+ }
501
+ function mergeStructural(cells) {
502
+ const out = [];
503
+ for (const cell of cells) {
504
+ const prev = out[out.length - 1];
505
+ if (prev !== void 0 && isStructural(prev) && isStructural(cell)) {
506
+ prev.notes = [...prev.notes, ...cell.notes];
507
+ if (cell.kind === "bar") prev.kind = "bar";
508
+ } else out.push(cell);
509
+ }
510
+ return out;
511
+ }
512
+ var SECTION_BARS = /* @__PURE__ */ new Set(["double", "quad"]);
513
+ var isBarCell = (cell) => cell?.kind === "bar";
514
+ var endsSection = (cell) => isBarCell(cell) && SECTION_BARS.has(cell.notes[cell.notes.length - 1].bar);
515
+ function alignRows(rows) {
516
+ const counts = /* @__PURE__ */ new Map();
517
+ for (const cells of rows)
518
+ if (isBarCell(cells[0])) counts.set(cells.length, (counts.get(cells.length) ?? 0) + 1);
519
+ let width = 0;
520
+ let best = 0;
521
+ for (const [len, n] of counts)
522
+ if (n > best || n === best && len > width) {
523
+ width = len;
524
+ best = n;
525
+ }
526
+ if (width === 0) for (const cells of rows) width = Math.max(width, cells.length);
527
+ const offsets = rows.map((cells, i) => {
528
+ if (cells.length === 0 || cells.length >= width) return 0;
529
+ const prev = rows[i - 1];
530
+ const phraseStart = !isBarCell(cells[0]) || i === 0 || endsSection(prev?.[prev.length - 1]);
531
+ if (!phraseStart) return 0;
532
+ return Math.max(0, width - cells.length - (isBarCell(cells[cells.length - 1]) ? 0 : 1));
533
+ });
534
+ rows.forEach((cells, i) => {
535
+ width = Math.max(width, offsets[i] + cells.length);
536
+ });
537
+ return { width, offsets };
464
538
  }
465
- function resolveArcs(cells, noteCount) {
539
+ function resolveArcs(cells) {
466
540
  const arcs = [];
467
541
  let openStart;
468
542
  let sawEnds = false;
469
- const fillCols = [];
470
- const openAt = (col) => {
543
+ const fillCells = [];
544
+ const openAt = (i) => {
471
545
  sawEnds = true;
472
- if (openStart === void 0) openStart = col;
546
+ if (openStart === void 0) openStart = i;
473
547
  };
474
- const closeAt = (col) => {
548
+ const closeAt = (i) => {
475
549
  sawEnds = true;
476
550
  if (openStart !== void 0) {
477
- arcs.push({ startCol: openStart, endCol: col, openLeft: false, openRight: false });
551
+ arcs.push({ start: openStart, end: i, openLeft: false, openRight: false });
478
552
  openStart = void 0;
479
553
  } else {
480
- arcs.push({ startCol: 1, endCol: col, openLeft: true, openRight: false });
554
+ arcs.push({ start: 0, end: i, openLeft: true, openRight: false });
481
555
  }
482
556
  };
483
- for (const cell of cells) {
484
- const firstCol = cell.start + 1;
485
- const lastCol = cell.start + cell.notes.length;
486
- cell.notes.forEach((note, j) => {
487
- const col = cell.start + j + 1;
488
- if (note.meend === "start") openAt(col);
489
- else if (note.meend === "end") closeAt(col);
557
+ cells.forEach((cell, i) => {
558
+ for (const note of cell.notes) {
559
+ if (note.meend === "start") openAt(i);
560
+ else if (note.meend === "end") closeAt(i);
490
561
  for (const b of note.below ?? []) {
491
562
  if (!isArcRaw(b.raw)) continue;
492
563
  for (const ch of b.raw) {
493
- if (ch === "w" || ch === "W") openAt(firstCol);
494
- else if (ch === "x" || ch === "X") closeAt(lastCol);
495
- else fillCols.push(firstCol, lastCol);
564
+ if (ch === "w" || ch === "W") openAt(i);
565
+ else if (ch === "x" || ch === "X") closeAt(i);
566
+ else fillCells.push(i);
496
567
  }
497
568
  }
498
- });
499
- }
569
+ }
570
+ });
500
571
  if (openStart !== void 0) {
501
- arcs.push({ startCol: openStart, endCol: Math.max(noteCount, 1), openLeft: false, openRight: true });
502
- } else if (!sawEnds && fillCols.length > 0) {
572
+ arcs.push({ start: openStart, end: Math.max(cells.length - 1, 0), openLeft: false, openRight: true });
573
+ } else if (!sawEnds && fillCells.length > 0) {
503
574
  arcs.push({
504
- startCol: Math.min(...fillCols),
505
- endCol: Math.max(...fillCols),
575
+ start: Math.min(...fillCells),
576
+ end: Math.max(...fillCells),
506
577
  openLeft: true,
507
578
  openRight: true,
508
579
  line: true
@@ -554,20 +625,43 @@ function cutGlyphs(marks, ctx) {
554
625
  return ctx.lang === "bn" ? `<span class="sw-cl" title="${ctx.marks.cut}">${marks}</span>` : `<span class="sw-cut" title="${ctx.marks.cut}"></span>`.repeat(marks.length);
555
626
  }
556
627
  function cellMatra(cell) {
557
- const total = cell.notes.reduce((a, n) => a + (n.durationMatra ?? 1), 0);
558
- const half = cell.notes.some((n) => n.halfMatra === true);
628
+ const pitched = cell.notes.filter(isPitched);
629
+ const total = pitched.reduce((a, n) => a + (n.durationMatra ?? 1), 0);
630
+ const half = pitched.some((n) => n.halfMatra === true);
559
631
  const whole = Math.floor(total + EPS);
560
632
  return { matras: half ? whole : Math.max(1, whole), half };
561
633
  }
562
634
  function cellPitchedOpts(cell) {
563
635
  const { matras, half } = cellMatra(cell);
564
- const marks = rawMarks(cell.notes[0].raw, cell.notes.length);
636
+ const pitched = [];
637
+ cell.notes.forEach((n, j) => {
638
+ if (isPitched(n)) pitched.push(j);
639
+ });
640
+ const first = pitched[0];
641
+ const last = pitched[pitched.length - 1];
642
+ const marks = rawMarks(first === void 0 ? void 0 : cell.notes[first].raw, pitched.length);
565
643
  const aakars = marks !== void 0 && !marks.aakar ? 0 : matras;
566
- return cell.notes.map((_, j) => ({
567
- matras: j === cell.notes.length - 1 ? aakars : 0,
568
- pre: j === 0 ? marks?.pre ?? "" : "",
569
- post: marks !== void 0 ? marks.post[j] : half && j === cell.notes.length - 1 ? "i" : ""
570
- }));
644
+ return cell.notes.map((note, j) => {
645
+ if (!isPitched(note)) return { matras: 0, pre: "", post: "" };
646
+ return {
647
+ matras: j === last ? aakars : 0,
648
+ pre: j === first ? marks?.pre ?? "" : "",
649
+ post: marks !== void 0 ? marks.post[pitched.indexOf(j)] : half && j === last ? "i" : ""
650
+ };
651
+ });
652
+ }
653
+ function cellLyricSlices(cell) {
654
+ if (!cell.notes.some((n) => n.lyric !== void 0)) return void 0;
655
+ const pitched = cell.notes.filter(isPitched);
656
+ if (pitched.length === 0) return cell.notes.map((n) => n.lyric ?? "");
657
+ const slices = new Array(pitched.length).fill("");
658
+ let k = 0;
659
+ for (const note of cell.notes) {
660
+ const at = Math.min(k, pitched.length - 1);
661
+ slices[at] += note.lyric ?? "";
662
+ if (isPitched(note)) k++;
663
+ }
664
+ return slices;
571
665
  }
572
666
  function renderPitchedInner(note, opts, ctx) {
573
667
  let inner = bracketGlyphs(note, "open");
@@ -645,64 +739,56 @@ function renderAnnotation(notes, ctx) {
645
739
  function lyricText(s, ctx) {
646
740
  return s.split("\u09F9").map((part) => part === "" ? "" : ctx.tr(part)).join(ctx.marks.melisma);
647
741
  }
648
- function renderRow(row, partIdx, rowIdx, ctx) {
742
+ var COL_BASE = 2;
743
+ function renderNote(note, kind, opts, ctx) {
744
+ if (note.bar !== void 0) return renderBarInner(note);
745
+ if (kind === "group" && opts !== void 0) return renderPitchedInner(note, opts, ctx);
746
+ const raw = rawDisplay(note);
747
+ return bracketGlyphs(note, "open") + (raw !== void 0 ? `<span class="sw-raw" title="${escapeHtml(raw)}">${escapeHtml(raw)}</span>` : "") + bracketGlyphs(note, "close");
748
+ }
749
+ function renderArc(arc, offset) {
750
+ const open = arc.line === true;
751
+ const cls = [
752
+ "sw-arc",
753
+ arc.openLeft || open ? "sw-arc--openl" : "",
754
+ arc.openRight || open ? "sw-arc--openr" : ""
755
+ ].filter(Boolean).join(" ");
756
+ const left = open || arc.openLeft ? "" : '<i class="sw-arc-l">w</i>';
757
+ const right = open || arc.openRight ? "" : '<i class="sw-arc-r">x</i>';
758
+ const from = offset + arc.start + COL_BASE;
759
+ const to = offset + arc.end + COL_BASE + 1;
760
+ return `<span class="${cls}" style="grid-column:${from} / ${to}">${left}<i class="sw-arc-m"></i>${right}</span>`;
761
+ }
762
+ function renderRow(row, partIdx, rowIdx, offset, ctx) {
649
763
  const cells = groupRowIntoCells(row.notes);
650
- const arcs = resolveArcs(cells, row.notes.length);
651
- const above = [];
652
- const notation = [];
653
- const below = [];
654
- const lyric = [];
655
- for (const cell of cells) {
656
- const firstCol = cell.start + 1;
657
- const lastCol = cell.start + cell.notes.length;
658
- const cellSpan = lastCol > firstCol ? `grid-column:${firstCol} / ${lastCol + 1}` : `grid-column:${firstCol}`;
764
+ const out = [];
765
+ if (row.label !== void 0)
766
+ out.push(`<span class="sw-label">${escapeHtml(ctx.tr(row.label))}</span>`);
767
+ cells.forEach((cell, ci) => {
768
+ const col = offset + ci + COL_BASE;
659
769
  const opts = cell.kind === "group" ? cellPitchedOpts(cell) : [];
660
- cell.notes.forEach((note, j) => {
661
- const col = cell.start + j + 1;
770
+ const barCls = cell.kind === "bar" ? " sw-bar-cell" : "";
771
+ const notes = cell.notes.map((note, j) => {
662
772
  const path = `${partIdx}.${rowIdx}.${cell.start + j}`;
663
- const cellEdges = `${j === 0 ? " sw-cs" : ""}${j === cell.notes.length - 1 ? " sw-ce" : ""}`;
664
- if (cell.kind === "bar") {
665
- notation.push(
666
- `<span class="sw-bar sw-bar--${note.bar}${cellEdges}" data-note="${path}" style="grid-column:${col}">${renderBarInner(note)}</span>`
667
- );
668
- } else if (cell.kind === "group") {
669
- notation.push(
670
- `<span class="sw-n${cellEdges}" data-note="${path}" style="grid-column:${col}">${renderPitchedInner(note, opts[j], ctx)}</span>`
671
- );
672
- } else {
673
- const raw = rawDisplay(note);
674
- const inner = bracketGlyphs(note, "open") + (raw !== void 0 ? `<span class="sw-raw" title="${escapeHtml(raw)}">${escapeHtml(raw)}</span>` : "") + bracketGlyphs(note, "close");
675
- if (inner !== "") {
676
- notation.push(
677
- `<span class="sw-n sw-carrier${cellEdges}" data-note="${path}" style="grid-column:${col}">${inner}</span>`
678
- );
679
- }
680
- }
681
- if (note.lyric !== void 0) {
682
- lyric.push(
683
- `<span class="sw-lyr${j === 0 ? " sw-cs" : ""}" style="grid-column:${col}">${escapeHtml(lyricText(note.lyric, ctx))}</span>`
684
- );
685
- }
686
- if (note.above !== void 0 && note.above.length > 0) {
687
- above.push(`<span class="sw-above" style="${cellSpan}">${renderAnnotation(note.above, ctx)}</span>`);
688
- }
689
- const nonArc = (note.below ?? []).filter((b) => !isArcRaw(b.raw));
690
- if (nonArc.length > 0) {
691
- below.push(`<span class="sw-below" style="${cellSpan}">${renderAnnotation(nonArc, ctx)}</span>`);
692
- }
693
- });
694
- }
695
- for (const arc of arcs) {
696
- const cls = [
697
- "sw-arc",
698
- arc.openLeft ? "sw-arc--openl" : "",
699
- arc.openRight ? "sw-arc--openr" : "",
700
- arc.line === true ? "sw-arc--line" : ""
701
- ].filter(Boolean).join(" ");
702
- below.push(`<span class="${cls}" style="grid-column:${arc.startCol} / ${arc.endCol + 1}"></span>`);
703
- }
704
- const label = row.label !== void 0 ? `<span class="sw-label">${escapeHtml(ctx.tr(row.label))}</span>` : "";
705
- return `<div class="sw-row">${label}<div class="sw-grid">${above.join("")}${notation.join("")}${below.join("")}${lyric.join("")}</div></div>`;
773
+ const cls = isPitched(note) ? " sw-p" : "";
774
+ const bar = note.bar !== void 0 ? ` sw-bar sw-bar--${note.bar}` : "";
775
+ return `<span class="sw-n${cls}${bar}" data-note="${path}">${renderNote(note, cell.kind, opts[j], ctx)}</span>`;
776
+ }).join("");
777
+ out.push(`<span class="sw-cell${barCls}" style="grid-column:${col}">${notes}</span>`);
778
+ const slices = cellLyricSlices(cell);
779
+ if (slices !== void 0) {
780
+ const html = slices.map((t) => `<span>${escapeHtml(lyricText(t, ctx))}</span>`).join("");
781
+ out.push(`<span class="sw-lyr" style="grid-column:${col}">${html}</span>`);
782
+ }
783
+ const above = cell.notes.flatMap((n) => n.above ?? []);
784
+ if (above.length > 0)
785
+ out.push(`<span class="sw-above" style="grid-column:${col}">${renderAnnotation(above, ctx)}</span>`);
786
+ const below = cell.notes.flatMap((n) => (n.below ?? []).filter((b) => !isArcRaw(b.raw)));
787
+ if (below.length > 0)
788
+ out.push(`<span class="sw-below" style="grid-column:${col}">${renderAnnotation(below, ctx)}</span>`);
789
+ });
790
+ for (const arc of resolveArcs(cells)) out.push(renderArc(arc, offset));
791
+ return `<div class="sw-row">${out.join("")}</div>`;
706
792
  }
707
793
  function renderHeader(meta, ctx) {
708
794
  return renderMetaHeader(meta, ctx.lang, ctx.tr);
@@ -725,19 +811,45 @@ function renderToHtml(doc, opts = {}) {
725
811
  const lang = opts.lang === "hi" || opts.lang === "en" ? opts.lang : "bn";
726
812
  const tr = lang !== "bn" && opts.transliterate ? opts.transliterate : (s) => s;
727
813
  const ctx = { lang, marks: MARKS[lang], tr };
814
+ const layout = alignRows(doc.parts.flatMap((p) => p.rows.map((r) => groupRowIntoCells(r.notes))));
815
+ const tracks = `grid-template-columns:max-content repeat(${layout.width},minmax(var(--sw-matra, 1.4em),max-content))`;
728
816
  const out = [`<div class="sw-sheet" lang="${lang}">`];
729
817
  if (opts.showHeader !== false) out.push(renderHeader(doc.meta, ctx));
818
+ out.push(`<div class="sw-scroll"><div class="sw-block" style="${tracks}">`);
819
+ let n = 0;
730
820
  doc.parts.forEach((part, p) => {
731
821
  out.push('<section class="sw-part">');
732
822
  if (part.name !== void 0)
733
823
  out.push(`<h3 class="sw-part-name">${escapeHtml(ctx.tr(part.name))}</h3>`);
734
- part.rows.forEach((row, r) => out.push(renderRow(row, p, r, ctx)));
824
+ part.rows.forEach((row, r) => out.push(renderRow(row, p, r, layout.offsets[n++], ctx)));
735
825
  out.push("</section>");
736
826
  });
737
- out.push("</div>");
827
+ out.push("</div></div></div>");
738
828
  return out.join("");
739
829
  }
740
830
 
831
+ // src/viewer/fit.ts
832
+ function fitSwaralipi(root, minScale = 0, zoom = 1) {
833
+ const sheets = root.querySelectorAll(".sw-sheet");
834
+ for (const sheet of sheets) {
835
+ const scroll = sheet.querySelector(".sw-scroll");
836
+ const block = sheet.querySelector(".sw-block");
837
+ if (scroll === null || block === null) continue;
838
+ sheet.style.removeProperty("--sw-size");
839
+ const available = scroll.clientWidth;
840
+ const natural = block.scrollWidth;
841
+ if (available === 0 || natural === 0) continue;
842
+ const fit = natural > available ? Math.max(minScale, available / natural) : 1;
843
+ const scale = fit * zoom;
844
+ if (scale === 1) continue;
845
+ const base = parseFloat(getComputedStyle(sheet).fontSize);
846
+ sheet.style.setProperty("--sw-size", `${(base * scale).toFixed(3)}px`);
847
+ }
848
+ }
849
+ var MIN_ZOOM = 0.5;
850
+ var MAX_ZOOM = 3;
851
+ var ZOOM_STEP = 1.2;
852
+
741
853
  // src/viewer/styles.ts
742
854
  var NOTATION_FONT_URLS = {
743
855
  Swarabitan: "https://lipighor.pages.dev/swarabitan/Swarabitan.ttf",
@@ -775,30 +887,42 @@ ${FONT_FACES}
775
887
 
776
888
  .sw-part-name { font-size: 0.85em; font-weight: 600; margin: 1em 0 0.2em; opacity: 0.85; }
777
889
 
778
- /* ---- row grid: above / notation / below(arcs) / lyric ---- */
779
- .sw-row {
780
- display: flex;
781
- align-items: flex-start;
782
- overflow-x: auto;
783
- padding: 0.4em 0;
890
+ /* ---- block grid: ONE column set for the whole sheet ----
891
+ * NLTR emits a song as a single html table, which is why its lines align
892
+ * down the page. Same here: .sw-block owns the tracks, every .sw-row
893
+ * borrows them with subgrid, and .sw-part is display:contents so section
894
+ * headings sit in the same grid instead of breaking it. */
895
+ .sw-scroll { overflow-x: auto; }
896
+ .sw-block {
897
+ display: grid;
898
+ column-gap: var(--sw-cell-gap, 0.4em);
899
+ align-items: end;
900
+ width: max-content;
784
901
  }
785
- .sw-label { flex: none; margin-right: 0.8em; font-weight: 600; }
786
- .sw-grid {
902
+ .sw-part { display: contents; }
903
+ .sw-row {
904
+ grid-column: 1 / -1;
787
905
  display: grid;
906
+ grid-template-columns: subgrid;
788
907
  grid-template-rows: auto auto auto auto;
789
- grid-auto-columns: max-content;
790
908
  align-items: end;
909
+ padding: 0.4em 0;
791
910
  }
792
- .sw-above { grid-row: 1; font-size: 0.62em; justify-self: start; white-space: nowrap; padding-left: 0.2em; }
793
- .sw-n { grid-row: 2; justify-self: center; white-space: nowrap; }
911
+ .sw-label { grid-column: 1; grid-row: 2; padding-right: 0.5em; font-weight: 600; }
912
+
913
+ /* One column per cell; sub-notes are equal slices of it (min-width:0 kills
914
+ * flex's min-content floor, else a wide sub-note steals width from its
915
+ * neighbours while the syllable below it does not, and the two bands drift). */
916
+ .sw-cell, .sw-lyr { display: flex; justify-content: center; }
917
+ .sw-cell > .sw-n { white-space: nowrap; }
918
+ .sw-cell > .sw-n.sw-p,
919
+ .sw-lyr > span { flex: 1 1 0; min-width: 0; text-align: center; white-space: nowrap; }
920
+ .sw-cell { grid-row: 2; }
921
+ .sw-above { grid-row: 1; font-size: 0.62em; justify-self: center; white-space: nowrap; }
794
922
  .sw-arc,
795
923
  .sw-below { grid-row: 3; }
796
- .sw-lyr { grid-row: 4; justify-self: center; white-space: nowrap; padding-top: 0.3em; }
797
- .sw-below { font-size: 0.62em; justify-self: start; white-space: nowrap; padding-left: 0.2em; }
798
-
799
- /* inter-cell gap as padding on each cell's first notation item, so arcs
800
- * and above-spans crossing cells stay continuous */
801
- .sw-cs { padding-left: var(--sw-cell-gap, 0.55em); }
924
+ .sw-lyr { grid-row: 4; padding-top: 0.3em; }
925
+ .sw-below { font-size: 0.62em; justify-self: center; white-space: nowrap; }
802
926
 
803
927
  /* ---- notation clusters & marks ---- */
804
928
  .sw-cl { position: relative; display: inline-block; }
@@ -867,31 +991,41 @@ ${FONT_FACES}
867
991
  opacity: 0.55;
868
992
  }
869
993
 
870
- /* ---- bars: dari (A) and danda (l/L) Swarabitan glyphs ---- */
871
- /* horizontal gap before a bar comes from its own .sw-cs padding-left \u2014
872
- * never reset it with a padding shorthand here */
994
+ /* ---- bars: dari (A) and danda (l/L) Swarabitan glyphs, all langs ---- */
873
995
  .sw-bar {
874
- grid-row: 2;
875
996
  font-family: Swarabitan, var(--sw-font, sans-serif);
876
997
  white-space: nowrap;
877
- padding-right: 0.1em;
878
998
  }
879
999
  .sw-bar--quad { letter-spacing: 0.15em; }
1000
+ /* a bar owns a whole column but needs no matra of width */
1001
+ .sw-bar-cell { min-width: 0; }
880
1002
 
881
- /* ---- under-arcs (slurs decoded from w/x segments and meend) ---- */
1003
+ /* ---- \u09AE\u09C0\u09A1\u09BC under-arcs (from w/x segments and spec meend) ----
1004
+ * Swarabitan draws the arc as three pieces \u2014 'w' the opening hook, 'y' a
1005
+ * flat fill, 'x' the closing hook. We use the font's own hooks and stretch
1006
+ * the fill as a CSS bar (the 'y' glyph IS a plain rectangle), so one arc
1007
+ * spans any number of columns. An open side = the arc continues onto the
1008
+ * next system: drop that hook and leave the fill squared off. */
882
1009
  .sw-arc {
883
- align-self: start;
884
- height: 0.4em;
885
- margin: 0.05em 0.25em 0;
886
- border: 0.09em solid currentColor;
887
- border-top: none;
888
- border-bottom-left-radius: 0.6em;
889
- border-bottom-right-radius: 0.6em;
890
- opacity: 0.85;
891
- }
892
- .sw-arc--openl { border-left: none; border-bottom-left-radius: 0; }
893
- .sw-arc--openr { border-right: none; border-bottom-right-radius: 0; }
894
- .sw-arc--line { border-left: none; border-right: none; border-radius: 0; height: 0.22em; }
1010
+ display: flex;
1011
+ align-items: baseline;
1012
+ line-height: 1;
1013
+ font-family: Swarabitan, var(--sw-font, sans-serif);
1014
+ font-size: 0.9em;
1015
+ }
1016
+ .sw-arc-l, .sw-arc-r { flex: none; font-style: normal; }
1017
+ /* The fill's geometry is measured off the font's own 'y' glyph: ink 0.11em
1018
+ tall whose bottom sits 0.01em above the baseline. An empty inline-block
1019
+ takes its bottom margin edge as its baseline, so -0.01em lifts the bar to
1020
+ exactly where the hooks' flat stroke runs \u2014 no seam at either join. */
1021
+ .sw-arc-m {
1022
+ flex: 1 1 0;
1023
+ min-width: 0;
1024
+ align-self: baseline;
1025
+ height: 0.11em;
1026
+ margin-bottom: -0.01em;
1027
+ background: currentColor;
1028
+ }
895
1029
 
896
1030
  /* ---- annotation content ---- */
897
1031
  .sw-beat { position: relative; display: inline-block; padding: 0 0.1em; }
@@ -935,8 +1069,8 @@ var SWARALIPI_PRINT_CSS = `
935
1069
  @page { margin: 16mm; }
936
1070
  @media print {
937
1071
  .sw-sheet, .sw-staff { -webkit-print-color-adjust: exact; print-color-adjust: exact; color: #000; }
938
- .sw-row { overflow: visible !important; break-inside: avoid; page-break-inside: avoid; }
939
- .sw-part { break-inside: auto; }
1072
+ .sw-scroll { overflow: visible !important; }
1073
+ .sw-row { break-inside: avoid; page-break-inside: avoid; }
940
1074
  .sw-part-name, .sw-header { break-after: avoid-page; page-break-after: avoid; }
941
1075
  .sw-hl { background: none !important; }
942
1076
  /* Keep each engraved staff system whole across a page break (the abcjs line
@@ -1354,12 +1488,17 @@ ${SWARALIPI_STAFF_CSS}
1354
1488
  ${SWARALIPI_PRINT_CSS}
1355
1489
  ${watermarkCss(watermark)}
1356
1490
  html, body { margin: 0; }
1357
- body { color: #111; background: #fff; padding: 16px; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
1491
+ /* The printable width of A4 at the @page margin above. The sheet is one grid
1492
+ with one natural width, so the fit script needs a real page-sized box to
1493
+ measure against \u2014 the iframe's own width is not it. */
1494
+ body { width: 178mm; color: #111; background: #fff; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
1358
1495
  .sw-sheet { max-width: 100%; }
1359
1496
  .sw-footer { margin-top: 12px; padding-top: 6px; border-top: 1px solid #ddd; font-size: 12px; color: #555; text-align: right; }
1360
1497
  </style>
1361
1498
  </head>
1362
- <body>${top}${bodyHtml}${bottom}</body>
1499
+ <body>${top}${bodyHtml}${bottom}
1500
+ <script>window.__swFit=function(){(${fitSwaralipi.toString()})(document,0.45)};window.__swFit()</script>
1501
+ </body>
1363
1502
  </html>`;
1364
1503
  }
1365
1504
  function renderPrintDocument(doc, opts = {}) {
@@ -1402,6 +1541,7 @@ function printHtmlInIframe(html) {
1402
1541
  if (printed) return;
1403
1542
  printed = true;
1404
1543
  try {
1544
+ win.__swFit?.();
1405
1545
  win.focus();
1406
1546
  win.print();
1407
1547
  } catch {
@@ -1457,6 +1597,9 @@ function createElementClass() {
1457
1597
  #translitLang;
1458
1598
  #renderSeq = 0;
1459
1599
  #staffOptions = {};
1600
+ #resize;
1601
+ #fittedAt = -1;
1602
+ #zoom = 1;
1460
1603
  validate = false;
1461
1604
  constructor() {
1462
1605
  super();
@@ -1506,9 +1649,46 @@ function createElementClass() {
1506
1649
  connectedCallback() {
1507
1650
  this.#ensureTransliterator();
1508
1651
  this.#render();
1652
+ if (this.#resize === void 0 && typeof ResizeObserver !== "undefined") {
1653
+ this.#resize = new ResizeObserver(([entry]) => {
1654
+ const w = entry?.contentRect.width ?? -1;
1655
+ if (w === this.#fittedAt) return;
1656
+ this.#fittedAt = w;
1657
+ this.#fit();
1658
+ });
1659
+ this.#resize.observe(this);
1660
+ }
1509
1661
  }
1510
1662
  disconnectedCallback() {
1511
1663
  this.#abort?.abort();
1664
+ this.#resize?.disconnect();
1665
+ this.#resize = void 0;
1666
+ }
1667
+ /** Scale the notation to fit the available width, times the zoom factor. */
1668
+ #fit() {
1669
+ fitSwaralipi(this.#root, 0, this.#zoom);
1670
+ }
1671
+ /** Re-fit the current render (public for hosts that resize the sheet
1672
+ * themselves, e.g. a tab that was hidden while the document loaded). */
1673
+ fit() {
1674
+ this.#fittedAt = -1;
1675
+ this.#fit();
1676
+ }
1677
+ get zoom() {
1678
+ return this.#zoom;
1679
+ }
1680
+ set zoom(factor) {
1681
+ this.#zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, factor));
1682
+ this.#fit();
1683
+ }
1684
+ zoomIn() {
1685
+ this.zoom = this.#zoom * ZOOM_STEP;
1686
+ }
1687
+ zoomOut() {
1688
+ this.zoom = this.#zoom / ZOOM_STEP;
1689
+ }
1690
+ resetZoom() {
1691
+ this.zoom = 1;
1512
1692
  }
1513
1693
  async #load(url) {
1514
1694
  this.#abort?.abort();
@@ -1540,6 +1720,7 @@ function createElementClass() {
1540
1720
  }
1541
1721
  #render() {
1542
1722
  const seq = ++this.#renderSeq;
1723
+ this.#zoom = 1;
1543
1724
  if (this.#doc === void 0) {
1544
1725
  this.#root.innerHTML = this.#styleFallback();
1545
1726
  return;
@@ -1558,6 +1739,7 @@ function createElementClass() {
1558
1739
  transliterate: this.#translitLang === lang ? this.#translit : void 0
1559
1740
  });
1560
1741
  this.#root.innerHTML = this.#styleFallback() + html;
1742
+ this.#fit();
1561
1743
  }
1562
1744
  #renderError(message) {
1563
1745
  const div = document.createElement("div");
@@ -1607,18 +1789,23 @@ exports.DEFAULT_WATERMARK = DEFAULT_WATERMARK;
1607
1789
  exports.DIGITS = DIGITS;
1608
1790
  exports.LABELS = LABELS;
1609
1791
  exports.MARKS = MARKS;
1792
+ exports.MAX_ZOOM = MAX_ZOOM;
1793
+ exports.MIN_ZOOM = MIN_ZOOM;
1610
1794
  exports.NOTATION_FONT_URLS = NOTATION_FONT_URLS;
1611
1795
  exports.SWARALIPI_CSS = SWARALIPI_CSS;
1612
1796
  exports.SWARALIPI_PRINT_CSS = SWARALIPI_PRINT_CSS;
1613
1797
  exports.SWARALIPI_STAFF_CSS = SWARALIPI_STAFF_CSS;
1798
+ exports.ZOOM_STEP = ZOOM_STEP;
1614
1799
  exports.abcDuration = abcDuration;
1615
1800
  exports.abcToSvg = abcToSvg;
1801
+ exports.alignRows = alignRows;
1616
1802
  exports.buildAbc = buildAbc;
1617
1803
  exports.buildTaalAbc = buildTaalAbc;
1618
1804
  exports.cellMatra = cellMatra;
1619
1805
  exports.composeCluster = composeCluster;
1620
1806
  exports.defineSwaralipiSheet = defineSwaralipiSheet;
1621
1807
  exports.docToAbc = docToAbc;
1808
+ exports.fitSwaralipi = fitSwaralipi;
1622
1809
  exports.groupRowIntoCells = groupRowIntoCells;
1623
1810
  exports.loadLyricTransliterator = loadLyricTransliterator;
1624
1811
  exports.localizeDigits = localizeDigits;