@barmark/render 0.9.1 → 0.11.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/CHANGELOG.md CHANGED
@@ -6,6 +6,32 @@ package uses [semantic versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.11.0] - 2026-11-03
10
+
11
+ Needs `barmark` 0.14.0.
12
+
13
+ ### Added
14
+
15
+ - **Several parts** (barmark 0.14.0, grammar 7.4): a system draws as
16
+ stacked rows, one per declared part in score order, sharing the
17
+ section's columns, with the part's label in the gutter and one bar
18
+ number. A part tacet there gets an empty row marked "tacet"; a tutti
19
+ line stays one row. The header lists the parts. The model gains
20
+ `parts`, a `system` item (its `rows`, each a row or an error) and
21
+ `tacet` on a bar. A tutti bar the parts play differently (`%` or a
22
+ leading `/` after a system) is drawn as written, `%` included, with
23
+ `simile` on its cell, rather than as one part's chords.
24
+ - **One part's view**: the `part` option and attribute draw only the
25
+ part with that label, as a single-part chart, with "tacet" where it
26
+ rests. An undeclared label draws the whole chart with a warning.
27
+
28
+ ## [0.10.0] - 2026-11-03
29
+
30
+ ### Added
31
+
32
+ - **The part** a chart names (`part: Electric Guitar 1`) leads the
33
+ header line: "Electric Guitar 1 · G major". The model gains `part`.
34
+
9
35
  ## [0.9.1] - 2026-11-03
10
36
 
11
37
  ### Fixed
@@ -268,7 +294,9 @@ that needs no script or as the `<barmark-chart>` web component. Requires
268
294
  zoom without sideways scrolling, the element renders and re-renders, and
269
295
  a screen reader hears "bar 9: D". Run them with `bun run test:browser`.
270
296
 
271
- [Unreleased]: https://github.com/kevinpeckham/barmark/compare/render-v0.9.1...HEAD
297
+ [Unreleased]: https://github.com/kevinpeckham/barmark/compare/render-v0.11.0...HEAD
298
+ [0.11.0]: https://github.com/kevinpeckham/barmark/compare/render-v0.10.0...render-v0.11.0
299
+ [0.10.0]: https://github.com/kevinpeckham/barmark/compare/render-v0.9.1...render-v0.10.0
272
300
  [0.9.1]: https://github.com/kevinpeckham/barmark/compare/render-v0.9.0...render-v0.9.1
273
301
  [0.9.0]: https://github.com/kevinpeckham/barmark/compare/render-v0.8.0...render-v0.9.0
274
302
  [0.8.0]: https://github.com/kevinpeckham/barmark/compare/render-v0.7.3...render-v0.8.0
package/README.md CHANGED
@@ -77,6 +77,7 @@ the element loads, with `<style data-barmark-font>` holding
77
77
  | `rehearsalLetters` | `rehearsal-letters` (`rehearsalLetters`) | `false` | boxed A, B, C… on each section as played |
78
78
  | `numerals` | `numerals` (`numerals`) | none | `"nashville"` (6m⁷) or `"roman"` (vi⁷): each chord as its degree in its bar's key |
79
79
  | `wrap` | `wrap="off"` (`wrap`) | `true` | wrap a section's rows when it is wider than the chart's box; off, rows keep their columns and the chart overflows, for its page to scroll |
80
+ | `part` | `part` (`part`) | none | draw only the part with this label, as one player reads it: tutti bars, its own bars, and "tacet" where it rests |
80
81
  | `diagnostics` | | `false` | list the chart's errors and warnings below it |
81
82
  | `fontUrl` | | none | emit Edwin's `@font-face` rule before the chart |
82
83
  | (the `text` argument) | (`text`) | `null` | the chart to draw; wins over `src` and the script child |
@@ -185,6 +186,14 @@ section shows the numbers it was written with. A line with an error shows as wri
185
186
  with its message. `layoutChart(text)` returns all of this as data, if
186
187
  you want to draw it yourself.
187
188
 
189
+ A chart of several parts (barmark's grammar 7.4) draws each system as
190
+ stacked rows, one per part in score order, with the part's label in the
191
+ gutter and one bar number on the top row. A part tacet in a system gets
192
+ an empty row that says "tacet" in its first bar, so every system keeps its
193
+ shape; a tutti line is one row. The header lists the parts' names, and
194
+ each part row is announced by name to a screen reader. On a screen too
195
+ narrow for a section, its rows wrap one by one, as any row does.
196
+
188
197
  ## License
189
198
 
190
199
  MIT. Edwin is © MuseScore Limited, under the SIL Open Font License 1.1.
package/dist/element.js CHANGED
@@ -10,12 +10,14 @@ import { loadErrorContent, shadowContent, THEMES } from "./html.js";
10
10
  export function optionsFromAttributes(el) {
11
11
  const theme = el.getAttribute("theme");
12
12
  const numerals = NUMERALS.find((n) => n === el.getAttribute("numerals"));
13
+ const part = el.getAttribute("part");
13
14
  return {
14
15
  theme: THEMES.find((t) => t === theme) ?? "auto",
15
16
  barNumbers: el.getAttribute("bar-numbers") !== "off",
16
17
  rehearsalLetters: el.hasAttribute("rehearsal-letters"),
17
18
  wrap: el.getAttribute("wrap") !== "off",
18
19
  ...(numerals ? { numerals } : {}),
20
+ ...(part ? { part } : {}),
19
21
  };
20
22
  }
21
23
  /** What a chart draws: its `text` while that is a string, else `src`, else its script child. */
@@ -56,6 +58,7 @@ function defineElement() {
56
58
  "rehearsal-letters",
57
59
  "numerals",
58
60
  "wrap",
61
+ "part",
59
62
  ];
60
63
  #observer = new MutationObserver(() => this.#schedule());
61
64
  #renders = 0;
package/dist/html.d.ts CHANGED
@@ -14,6 +14,8 @@ export type RenderOptions = {
14
14
  * With `false` rows keep their columns, and the page scrolls the chart instead.
15
15
  */
16
16
  wrap?: boolean;
17
+ /** Draw only the part with this label (barmark's grammar 7.4): one player's view. */
18
+ part?: string;
17
19
  };
18
20
  export declare function renderChart(text: string, options?: RenderOptions): string;
19
21
  export declare const THEMES: readonly ["auto", "light", "dark"];
package/dist/html.js CHANGED
@@ -22,6 +22,7 @@ function elementAttributes(options) {
22
22
  options.rehearsalLetters ? " rehearsal-letters" : "",
23
23
  numeralsOf(options) ? ` numerals="${numeralsOf(options)}"` : "",
24
24
  options.wrap === false ? ' wrap="off"' : "",
25
+ options.part ? ` part="${escapeHtml(options.part)}"` : "",
25
26
  ].join("");
26
27
  }
27
28
  export const THEMES = ["auto", "light", "dark"];
@@ -37,18 +38,28 @@ export function renderChartUnwrapped(text, options = {}) {
37
38
  return `${fontStyle(options)}${chartHtml(text, options)}`;
38
39
  }
39
40
  function chartHtml(text, options) {
40
- const model = layoutChart(text, { rehearsalLetters: options.rehearsalLetters ?? false });
41
+ const model = layoutChart(text, {
42
+ rehearsalLetters: options.rehearsalLetters ?? false,
43
+ ...(options.part && { part: options.part }),
44
+ });
45
+ const barNumbers = options.barNumbers ?? true;
46
+ const parts = model.parts.length > 1;
41
47
  const settings = {
42
- barNumbers: options.barNumbers ?? true,
48
+ barNumbers,
49
+ gutter: barNumbers || parts,
43
50
  numerals: numeralsOf(options),
44
51
  };
45
- return `<div class="bm-chart" data-theme="${themeOf(options)}"${settings.barNumbers ? "" : ' data-bar-numbers="off"'}${options.wrap === false ? ' data-wrap="off"' : ""}>${headerHtml(model)}${model.blocks.map((b) => blockHtml(b, settings)).join("")}${options.diagnostics ? diagnosticsHtml(model) : ""}</div>`;
52
+ // Every section's left column is as wide as the longest part label, so tutti rows line up with systems.
53
+ const widest = Math.max(0, ...model.parts.map((p) => p.label?.length ?? 0));
54
+ const gutter = parts ? ` data-parts style="--bm-label-chars:${widest}"` : "";
55
+ return `<div class="bm-chart" data-theme="${themeOf(options)}"${gutter}${settings.barNumbers ? "" : ' data-bar-numbers="off"'}${options.wrap === false ? ' data-wrap="off"' : ""}>${headerHtml(model)}${model.blocks.map((b) => blockHtml(b, settings)).join("")}${options.diagnostics ? diagnosticsHtml(model) : ""}</div>`;
46
56
  }
47
57
  function headerHtml(model) {
48
58
  const title = model.title
49
59
  ? `<h2 class="bm-title" part="title">${escapeHtml(model.title)}</h2>`
50
60
  : "";
51
- const meta = metaText(model.header);
61
+ const names = model.parts.length > 1 ? model.parts.map((p) => p.name).join(", ") : model.part?.name;
62
+ const meta = [names ? escapeHtml(names) : "", metaText(model.header)].filter(Boolean).join(" · ");
52
63
  if (!title && !meta)
53
64
  return "";
54
65
  return `<header class="bm-header">${title}${meta ? `<p class="bm-meta" part="meta">${meta}</p>` : ""}</header>`;
@@ -142,6 +153,8 @@ function itemHtml(item, columns, settings) {
142
153
  switch (item.kind) {
143
154
  case "row":
144
155
  return rowHtml(item, columns, settings);
156
+ case "system":
157
+ return systemHtml(item, columns, settings);
145
158
  case "text":
146
159
  return `<p class="bm-text${item.note ? " bm-note" : ""}" part="text">${escapeHtml(item.text)}</p>`;
147
160
  case "directive":
@@ -155,23 +168,63 @@ function errorHtml(source, messages) {
155
168
  const message = escapeHtml(messages.join("; "));
156
169
  return `<div class="bm-error" title="${message}"><code>${escapeHtml(source)}</code><span class="bm-sr">Error: ${message}</span></div>`;
157
170
  }
158
- function rowHtml(row, columns, settings) {
159
- const number = settings.barNumbers
160
- ? `<span class="bm-bar-number" part="bar-number" aria-hidden="true">${row.barNumber ?? ""}</span>`
171
+ /** A system: its parts' rows stacked, sharing the section's columns (barmark's grammar 7.4). */
172
+ function systemHtml(item, columns, settings) {
173
+ const names = escapeHtml(item.rows.map((p) => p.name).join(", "));
174
+ const rows = item.rows.map((p, i) => p.row.kind === "error"
175
+ ? errorHtml(p.row.source, p.row.messages)
176
+ : rowHtml(p.row, columns, settings, { label: p.label, name: p.name, first: i === 0 }));
177
+ return `<div class="bm-system" role="group" aria-label="${names}">${rows.join("")}</div>`;
178
+ }
179
+ /** The left column: the bar number, and in a system the part's label. */
180
+ function gutterHtml(row, settings, part) {
181
+ if (!settings.gutter)
182
+ return "";
183
+ const number = settings.barNumbers && (!part || part.first) ? (row.barNumber ?? "") : "";
184
+ const label = part
185
+ ? `<span class="bm-part-label" part="part-label">${escapeHtml(part.label)}</span>`
161
186
  : "";
187
+ return `<span class="bm-bar-number" part="bar-number" aria-hidden="true">${label}${number}</span>`;
188
+ }
189
+ const isTacetCell = (c) => c.kind === "slots" && c.tacet === true;
190
+ /** A row's cells; a tacet row says "tacet" in its first bar. */
191
+ function rowCells(row, numerals, ending) {
162
192
  let n = row.barNumber;
163
- const label = row.ending ? endingLabel(row.ending) : null;
164
- const cells = row.cells.map((cell, i) => {
165
- const ending = i === 0 && label ? ` data-ending="${label.shown}"` : "";
166
- const html = cellHtml(cell, n, repeatMarks(row, i) + ending, settings.numerals);
193
+ const firstTacet = row.cells.findIndex(isTacetCell);
194
+ return row.cells.map((cell, i) => {
195
+ const marks = repeatMarks(row, i) + (i === 0 ? ending : "");
196
+ const spoken = `<span class="bm-sr">bar ${n ?? ""}: tacet</span>`;
197
+ const html = isTacetCell(cell)
198
+ ? tacetHtml(spoken, marks, i === firstTacet ? "tacet" : "")
199
+ : cellHtml(cell, n, marks, numerals);
167
200
  if (n !== null && cell.kind === "slots")
168
201
  n++;
169
202
  return html;
170
203
  });
171
- const empty = `<span class="bm-empty" aria-hidden="true"></span>`.repeat(Math.max(0, columns - row.cells.length));
204
+ }
205
+ const sr = (text) => (text ? `<span class="bm-sr">${text}</span>` : "");
206
+ /** The row's class list. */
207
+ function rowClasses(row, part) {
208
+ const classes = ["bm-row"];
209
+ if (part)
210
+ classes.push("bm-part-row");
211
+ if (part && row.cells.some(isTacetCell))
212
+ classes.push("bm-tacet");
213
+ if (row.ending)
214
+ classes.push("bm-ending");
215
+ return classes.join(" ");
216
+ }
217
+ /** What a screen reader hears before a row's bars: its part, a repeat start, its ending. */
218
+ function rowOpening(row, part, ending) {
172
219
  const opens = row.opens || typeof row.repeat === "number" || row.repeat === "open";
173
- const ending = label ? `<span class="bm-sr">${label.spoken}</span>` : "";
174
- return `<div class="bm-row${label ? " bm-ending" : ""}">${opens ? `<span class="bm-sr">repeat start</span>` : ""}${ending}${number}${cells.join("")}${empty}<span class="bm-repeat">${rowRepeatHtml(row)}</span></div>`;
220
+ return sr(part ? escapeHtml(part.name) : "") + sr(opens ? "repeat start" : "") + sr(ending);
221
+ }
222
+ function rowHtml(row, columns, settings, part) {
223
+ const label = row.ending ? endingLabel(row.ending) : null;
224
+ const cells = rowCells(row, settings.numerals, label ? ` data-ending="${label.shown}"` : "");
225
+ const empty = `<span class="bm-empty" aria-hidden="true"></span>`.repeat(Math.max(0, columns - row.cells.length));
226
+ const said = rowOpening(row, part, label?.spoken ?? "");
227
+ return `<div class="${rowClasses(row, part)}">${said}${gutterHtml(row, settings, part)}${cells.join("")}${empty}<span class="bm-repeat">${rowRepeatHtml(row)}</span></div>`;
175
228
  }
176
229
  /** An ending's passes as printed, "1.", "1.–3.", "1., 3.", and spoken, "ending for passes 1 to 3". */
177
230
  function endingLabel(passes) {
@@ -200,13 +253,23 @@ function cellHtml(cell, barNumber, marks, numerals) {
200
253
  const spoken = `<span class="bm-sr">${escapeHtml(speakBar(barNumber, cell, view !== null))}</span>`;
201
254
  if (cell.kind !== "slots")
202
255
  return betweenBarsHtml(cell, spoken, marks);
256
+ const change = cell.tempoChange ? ` data-tempo-change="${cell.tempoChange}"` : "";
257
+ const [body, end] = barBody(cell, view);
258
+ return `<div class="bm-bar" part="bar"${cell.cut ? " data-cut" : ""}${change}${marks}>${spoken}<span class="bm-bar-body" aria-hidden="true">${body}</span>${end}</div>`;
259
+ }
260
+ /** What a bar shows: its meter, its slots (or `%`), and "cut"; then the words after its last slot. */
261
+ function barBody(cell, view) {
203
262
  const meter = cell.meter
204
263
  ? `<span class="bm-meter"><span>${cell.meter.n}</span><span>${cell.meter.d}</span></span>`
205
264
  : "";
206
265
  const cut = cell.cut ? `<span class="bm-cut">cut</span>` : "";
207
- const change = cell.tempoChange ? ` data-tempo-change="${cell.tempoChange}"` : "";
208
- const [slots, end] = slotsWithWords(cell, view);
209
- return `<div class="bm-bar" part="bar"${cell.cut ? " data-cut" : ""}${change}${marks}>${spoken}<span class="bm-bar-body" aria-hidden="true">${meter}${slots.join("")}${cut}</span>${end}</div>`;
266
+ const [written, end] = slotsWithWords(cell, view);
267
+ const slots = cell.simile ? `<span class="bm-simile">%</span>` : written.join("");
268
+ return [`${meter}${slots}${cut}`, end];
269
+ }
270
+ /** A bar where the part rests; the first of a row says so, in its body. */
271
+ function tacetHtml(spoken, marks, text = "") {
272
+ return `<div class="bm-bar bm-tacet-bar" part="bar"${marks}>${spoken}<span class="bm-bar-body" aria-hidden="true">${text}</span></div>`;
210
273
  }
211
274
  /** A bar's slots, each under any words before it, and the words after the last slot. */
212
275
  function slotsWithWords(cell, view) {
package/dist/model.js CHANGED
@@ -4,9 +4,45 @@
4
4
  * draws. Built from the parser's document lines; the played bars supply
5
5
  * bar numbers and the slots `%` copies.
6
6
  */
7
- import { parseChart, } from "barmark";
7
+ import { extractPart, parseChart, } from "barmark";
8
+ /** Whether a tutti bar's parts play different slots, as `%` or `/` on a tutti line can. */
9
+ const playedApart = (b, line) => {
10
+ const own = (b.parts ?? []).filter((p) => p?.line === line).map((p) => JSON.stringify(p?.slots));
11
+ return new Set(own).size > 1;
12
+ };
13
+ /** Each part's bars on its own lines; a tutti bar once (barmark's grammar 7.4). */
14
+ function barsByLine(parse) {
15
+ if (parse.parts.length < 2)
16
+ return parse.bars;
17
+ const seen = new Set();
18
+ return parse.bars.flatMap((b) => (b.parts ?? []).flatMap((p) => {
19
+ const key = `${b.index}:${p?.line}`;
20
+ if (!p || seen.has(key))
21
+ return [];
22
+ seen.add(key);
23
+ const { words: _w, parts: _p, tacet: _t, ...shared } = b;
24
+ const apart = playedApart(b, p.line) ? { perPart: true } : {};
25
+ return [
26
+ { ...shared, ...apart, line: p.line, slots: p.slots, ...(p.words && { words: p.words }) },
27
+ ];
28
+ }));
29
+ }
30
+ /** The whole chart, or one part of it; an undeclared part draws the whole chart, with a warning. */
31
+ function parseFor(text, part) {
32
+ const one = part ? extractPart(text, part) : null;
33
+ if (one || !part)
34
+ return one ?? parseChart(text);
35
+ const whole = parseChart(text);
36
+ const missing = {
37
+ line: 1,
38
+ severity: "warning",
39
+ message: `no part labelled ${part}`,
40
+ token: part,
41
+ };
42
+ return { ...whole, diagnostics: [missing, ...whole.diagnostics] };
43
+ }
8
44
  export function layoutChart(text, options = {}) {
9
- const parse = parseChart(text);
45
+ const parse = parseFor(text, options.part);
10
46
  const { header, body } = splitHeader(parse.lines);
11
47
  const groups = groupLines(body);
12
48
  const ctx = {
@@ -14,17 +50,25 @@ export function layoutChart(text, options = {}) {
14
50
  source: text.split(/\r?\n/),
15
51
  errors: errorsByLine(parse),
16
52
  written: new Map(groups.flatMap((g) => (g.heading ? [[g.heading.line, g]] : []))),
53
+ bars: barsByLine(parse),
17
54
  };
18
55
  const blocks = groups.map((g) => layoutGroup(g, ctx));
19
56
  if (options.rehearsalLetters)
20
57
  letterBlocks(blocks);
21
- return { title: parse.title, header, blocks, diagnostics: parse.diagnostics };
58
+ return {
59
+ title: parse.title,
60
+ part: parse.part,
61
+ parts: parse.parts,
62
+ header,
63
+ blocks,
64
+ diagnostics: parse.diagnostics,
65
+ };
22
66
  }
23
67
  /** Rehearsal letters mark position: every section occurrence, and leading bars, in play order. */
24
68
  function letterBlocks(blocks) {
25
69
  let n = 0;
26
70
  for (const b of blocks) {
27
- if (b.form === null && !b.items.some((i) => i.kind === "row"))
71
+ if (b.form === null && !b.items.some((i) => i.kind === "row" || i.kind === "system"))
28
72
  continue;
29
73
  b.rehearsal = letters(n++);
30
74
  }
@@ -49,7 +93,7 @@ function splitHeader(lines) {
49
93
  let i = 0;
50
94
  for (; i < lines.length; i++) {
51
95
  const l = lines[i];
52
- if (l?.kind === "title")
96
+ if (l?.kind === "title" || l?.kind === "part")
53
97
  continue;
54
98
  if (l?.kind !== "directive")
55
99
  break;
@@ -58,7 +102,7 @@ function splitHeader(lines) {
58
102
  delete header.swingPercent;
59
103
  Object.assign(header, l.set);
60
104
  }
61
- return { header, body: lines.slice(i).filter((l) => l.kind !== "title") };
105
+ return { header, body: lines.slice(i).filter((l) => l.kind !== "title" && l.kind !== "part") };
62
106
  }
63
107
  function groupLines(lines) {
64
108
  const groups = [];
@@ -114,34 +158,61 @@ function substituteText(items, own) {
114
158
  return next ? [next] : [];
115
159
  });
116
160
  // Extra lines continue after the last row and any text already following it.
117
- const after = Math.max(out.findLastIndex((i) => i.kind === "row"), out.findLastIndex((i) => i.kind === "text"));
161
+ const after = Math.max(out.findLastIndex((i) => i.kind === "row" || i.kind === "system"), out.findLastIndex((i) => i.kind === "text"));
118
162
  out.splice(after + 1, 0, ...queue);
119
163
  return out;
120
164
  }
121
- /** The parser cuts the last bar it would play; a fermata is not a bar. */
122
- function markCut(items) {
123
- const r = items.findLastIndex((i) => i.kind === "row" && lastSlots(i.cells) !== null);
124
- const row = items[r];
125
- if (row?.kind !== "row")
126
- return items;
165
+ /** The row's last bar, marked cut. */
166
+ function cutRow(row) {
127
167
  const c = row.cells.findLastIndex((cell) => cell.kind === "slots");
128
168
  const cells = row.cells.map((cell, i) => i === c && cell.kind === "slots" ? { ...cell, cut: true } : cell);
129
- return items.map((item, i) => (i === r ? { ...row, cells } : item));
169
+ return { ...row, cells };
170
+ }
171
+ const hasBars = (i) => (i.kind === "row" && lastSlots(i.cells) !== null) || i.kind === "system";
172
+ /** The parser cuts the last bar it would play; a fermata is not a bar. A system's every part loses it. */
173
+ function markCut(items) {
174
+ const r = items.findLastIndex(hasBars);
175
+ const item = items[r];
176
+ if (!item || (item.kind !== "row" && item.kind !== "system"))
177
+ return items;
178
+ const cut = item.kind === "row"
179
+ ? cutRow(item)
180
+ : {
181
+ ...item,
182
+ rows: item.rows.map((p) => (p.row.kind === "row" ? { ...p, row: cutRow(p.row) } : p)),
183
+ };
184
+ return items.map((it, i) => (i === r ? cut : it));
130
185
  }
186
+ /** How many cells an item's widest row has. */
187
+ const widthOf = (i) => i.kind === "row"
188
+ ? i.cells.length
189
+ : i.kind === "system"
190
+ ? Math.max(0, ...i.rows.map((p) => (p.row.kind === "row" ? p.row.cells.length : 0)))
191
+ : 0;
131
192
  function blockOf(h, occurrence, items) {
132
193
  return {
133
194
  heading: h?.name ?? null,
134
195
  repeat: h?.repeat ?? null,
135
196
  form: occurrence?.form ?? null,
136
197
  rehearsal: null,
137
- columns: Math.max(0, ...items.map((i) => (i.kind === "row" ? i.cells.length : 0))),
198
+ columns: Math.max(0, ...items.map(widthOf)),
138
199
  items,
139
200
  };
140
201
  }
141
202
  function layoutLines(lines, occurrence, ctx) {
142
203
  const items = [];
143
204
  let previous = null;
144
- for (const l of lines) {
205
+ for (const [i, l] of lines.entries()) {
206
+ const system = systemOf(l);
207
+ if (system !== null) {
208
+ // A system is laid out once, at its first line.
209
+ if (systemOf(lines[i - 1]) === system)
210
+ continue;
211
+ const item = layoutSystem(systemLines(lines, i), occurrence, previous, ctx);
212
+ previous = lastOfSystem(item) ?? previous;
213
+ items.push(item);
214
+ continue;
215
+ }
145
216
  if (ctx.errors.has(l.line)) {
146
217
  items.push(errorItem(l.line, ctx));
147
218
  continue;
@@ -155,10 +226,65 @@ function layoutLines(lines, occurrence, ctx) {
155
226
  }
156
227
  return items;
157
228
  }
229
+ const systemOf = (l) => (l?.kind === "bars" || l?.kind === "invalid") && l.system !== undefined ? l.system : null;
230
+ /** The lines of the system that starts at `i`. */
231
+ function systemLines(lines, i) {
232
+ const system = systemOf(lines[i]);
233
+ const out = [];
234
+ for (const l of lines.slice(i)) {
235
+ if (systemOf(l) !== system)
236
+ break;
237
+ out.push(l);
238
+ }
239
+ return out;
240
+ }
241
+ const lastOfSystem = (item) => {
242
+ const first = item.rows.find((p) => p.row.kind === "row" && !p.row.cells.some(isTacet))?.row;
243
+ return first?.kind === "row" ? lastSlots(first.cells) : null;
244
+ };
245
+ const isTacet = (c) => c.kind === "slots" && c.tacet === true;
246
+ /** A row per declared part, in score order: its own line, its error, or tacet bars. */
247
+ function layoutSystem(lines, occurrence, previous, ctx) {
248
+ const ref = lines.find((l) => l.kind === "bars" && !ctx.errors.has(l.line));
249
+ const reference = ref
250
+ ? layoutRow(ref, playedOn(ref.line, occurrence, ctx.bars), previous, ctx)
251
+ : null;
252
+ const rows = ctx.parse.parts.map((p) => {
253
+ const own = lines.find((l) => l.part === p.label);
254
+ const row = own && ctx.errors.has(own.line)
255
+ ? errorItem(own.line, ctx)
256
+ : own?.kind === "bars"
257
+ ? layoutRow(own, playedOn(own.line, occurrence, ctx.bars), previous, ctx)
258
+ : tacetRow(reference, lines[0]?.line ?? 0);
259
+ return { label: p.label ?? "", name: p.name, row };
260
+ });
261
+ return {
262
+ kind: "system",
263
+ line: lines[0]?.line ?? 0,
264
+ barNumber: reference?.barNumber ?? null,
265
+ rows,
266
+ };
267
+ }
268
+ const NO_ROW = {
269
+ kind: "row",
270
+ line: 0,
271
+ barNumber: null,
272
+ opens: false,
273
+ closes: false,
274
+ repeat: null,
275
+ ending: null,
276
+ cells: [],
277
+ };
278
+ /** The system's bars, with nothing played in them (barmark's grammar 7.4). */
279
+ function tacetRow(reference, line) {
280
+ const base = reference ?? NO_ROW;
281
+ const cells = base.cells.map((c) => c.kind === "slots" ? { kind: "slots", meter: c.meter, slots: [], cut: false, tacet: true } : c);
282
+ return { ...base, line, cells };
283
+ }
158
284
  function layoutLine(l, occurrence, previous, ctx) {
159
285
  switch (l.kind) {
160
286
  case "bars":
161
- return layoutRow(l, playedOn(l.line, occurrence, ctx.parse.bars), previous, ctx);
287
+ return layoutRow(l, playedOn(l.line, occurrence, ctx.bars), previous, ctx);
162
288
  case "text":
163
289
  return textItem(l.text);
164
290
  case "directive":
@@ -193,7 +319,7 @@ function playedOn(line, occurrence, bars) {
193
319
  function layoutRow(l, played, previous, ctx) {
194
320
  const cells = [];
195
321
  // A cut bar is not played: what it would play is what the line's bar played when written.
196
- const written = ctx.parse.bars.filter((b) => b.line === l.line);
322
+ const written = ctx.bars.filter((b) => b.line === l.line);
197
323
  let bar = 0;
198
324
  let prev = previous;
199
325
  for (const cell of l.cells) {
@@ -222,8 +348,16 @@ function layoutCell(cell, played, previous) {
222
348
  return { kind: "fermata" };
223
349
  if (cell.kind === "direction" || cell.kind === "rehearsal")
224
350
  return cell;
351
+ // Parts that play a tutti bar differently each read their own: draw it as written.
352
+ if (played?.perPart)
353
+ return withPlayed(asWritten(cell, previous), played);
225
354
  return withPlayed(layoutBar(cell, played, previous), played);
226
355
  }
356
+ /** A tutti bar as written: its own slots, holds as holds, or `%` itself. */
357
+ function asWritten(cell, previous) {
358
+ const bar = layoutBar(cell, undefined, previous);
359
+ return cell.kind === "simile" ? { ...bar, slots: [], simile: true } : bar;
360
+ }
227
361
  /** A bar or `%`, with the slots it plays. */
228
362
  function layoutBar(cell, played, previous) {
229
363
  const own = cell.kind === "slots" ? cell : null;
@@ -235,8 +369,11 @@ function layoutBar(cell, played, previous) {
235
369
  ...wordsOf(cell),
236
370
  };
237
371
  }
238
- /** The words written on a bar, as a field to spread; nothing when it has none. */
239
- const wordsOf = (cell) => cell.words ? { words: cell.words } : {};
372
+ /** The words written on a bar, and whether the part rests there, as fields to spread. */
373
+ const wordsOf = (cell) => ({
374
+ ...(cell.words && { words: cell.words }),
375
+ ...(cell.kind === "slots" && cell.tacet && { tacet: cell.tacet }),
376
+ });
240
377
  /** What the parser says about the bar as played: inside a rit. or accel., and its key. */
241
378
  function withPlayed(cell, played) {
242
379
  const change = played?.tempoChange?.kind;
package/dist/speech.js CHANGED
@@ -148,6 +148,11 @@ export function speakBar(barNumber, cell, numerals = false) {
148
148
  return speakDirection(cell.direction);
149
149
  if (cell.kind === "rehearsal")
150
150
  return `rehearsal ${cell.label}`;
151
+ const bar = `bar${barNumber === null ? "" : ` ${barNumber}`}`;
152
+ if (cell.tacet)
153
+ return `${bar}: tacet`;
154
+ if (cell.simile)
155
+ return `${bar}: each part repeats its bar before`;
151
156
  const meter = cell.meter ? `, in ${cell.meter.n}/${cell.meter.d}` : "";
152
157
  const cut = cell.cut ? ", cut on this pass" : "";
153
158
  const words = (at) => (cell.words ?? []).filter((w) => w.at === at).map((w) => `“${w.text}”`);
package/dist/styles.js CHANGED
@@ -85,7 +85,14 @@ const css = `
85
85
  .bm-grid{display:grid;grid-template-columns:${em(GUTTER)} repeat(var(--bm-cols),auto) auto;width:max-content;max-width:100%}
86
86
  .bm-chart[data-bar-numbers="off"] .bm-grid{grid-template-columns:repeat(var(--bm-cols),auto) auto}
87
87
  .bm-chart[data-wrap="off"] .bm-grid{max-width:none}
88
- .bm-row{display:contents}
88
+ .bm-row,.bm-system{display:contents}
89
+ .bm-chart[data-parts] .bm-grid{grid-template-columns:calc(${em(GUTTER + 0.9)} + var(--bm-label-chars) * .62em) repeat(var(--bm-cols),auto) auto}
90
+ .bm-chart[data-parts] .bm-bar-number{width:auto;justify-content:flex-end;gap:.35em;padding-right:.45em}
91
+ .bm-part-row>.bm-bar-number{border-right:.15em solid var(--bm-rule)}
92
+ .bm-part-label{margin-right:auto}
93
+ .bm-part-label{font-family:${LABEL};font-weight:600;color:var(--bm-ink)}
94
+ .bm-tacet-bar .bm-bar-body{font-family:${LABEL};font-style:italic;color:var(--bm-muted)}
95
+ :is(.bm-row,.bm-system,.bm-error)+.bm-system>.bm-row:first-child>*,.bm-system+.bm-row>*{margin-top:${em(ROW_GAP * 2)}}
89
96
  .bm-row+.bm-row>*{margin-top:${em(ROW_GAP)}}
90
97
  .bm-row+.bm-row>.bm-bar-number{margin-top:${at(SIZES.barNumber, ROW_GAP)}}
91
98
  .bm-bar-number{display:flex;flex:none;align-items:baseline;width:${at(SIZES.barNumber, GUTTER)};padding-top:${at(SIZES.barNumber, 0.65)};font:${em(SIZES.barNumber)}/1 ${SANS};color:var(--bm-muted)}
@@ -102,7 +109,7 @@ const css = `
102
109
  .bm-chord{white-space:nowrap}
103
110
  .bm-chord sup{font-size:${em(SIZES.superscript)};vertical-align:.55em;line-height:0}
104
111
  .bm-chord sub{font-size:${em(SIZES.octave)};line-height:0}
105
- .bm-hold,.bm-unknown,.bm-brace{color:var(--bm-muted)}
112
+ .bm-hold,.bm-unknown,.bm-brace,.bm-simile{color:var(--bm-muted)}
106
113
  .bm-nc{font-size:${em(SIZES.noChord)};letter-spacing:.06em}
107
114
  .bm-silence{display:inline-block;width:.9em;height:.32em;background:var(--bm-ink);vertical-align:.25em}
108
115
  .bm-ring,.bm-notes{font-size:${em(SIZES.noteNames)}}
package/dist/types.d.ts CHANGED
@@ -1,10 +1,16 @@
1
- import type { ChartDiagnostic, Direction, Directives, Key, Meter, Repeat, Slot, Words } from "barmark";
1
+ import type { ChartDiagnostic, Direction, Directives, Key, Meter, Part, Repeat, Slot, Words } from "barmark";
2
+ /** `part`: draw only the part with this label (barmark's grammar 7.4), as a single-part chart. */
2
3
  export type LayoutOptions = {
3
4
  rehearsalLetters?: boolean;
5
+ part?: string;
4
6
  };
5
7
  /** What to draw, with every musical decision already made. */
6
8
  export type ChartModel = {
7
9
  title: string | null;
10
+ /** What `part:` names in a single-part chart; null when the chart names none or several. */
11
+ part: Part | null;
12
+ /** Every declared part, in score order (barmark's grammar 7.4). */
13
+ parts: Part[];
8
14
  /** Directives before the first heading, bar line or text line. */
9
15
  header: Directives;
10
16
  blocks: SectionBlock[];
@@ -22,6 +28,9 @@ export type SectionBlock = {
22
28
  columns: number;
23
29
  items: BlockItem[];
24
30
  };
31
+ export type RowItem = Extract<BlockItem, {
32
+ kind: "row";
33
+ }>;
25
34
  export type BlockItem = {
26
35
  kind: "row";
27
36
  line: number;
@@ -34,6 +43,21 @@ export type BlockItem = {
34
43
  ending: number[] | null;
35
44
  cells: CellModel[];
36
45
  }
46
+ /**
47
+ * A system (barmark's grammar 7.4): a row per declared part, in score
48
+ * order, sharing bars. A part tacet there has a row of `tacet` bars; a
49
+ * part whose line has an error has an error item.
50
+ */
51
+ | {
52
+ kind: "system";
53
+ line: number;
54
+ barNumber: number | null;
55
+ rows: Array<{
56
+ label: string;
57
+ name: string;
58
+ row: RowItem | ErrorItem;
59
+ }>;
60
+ }
37
61
  /** `note` marks a `###` line, whose text is given without the marks. */
38
62
  | {
39
63
  kind: "text";
@@ -42,7 +66,8 @@ export type BlockItem = {
42
66
  } | {
43
67
  kind: "directive";
44
68
  set: Directives;
45
- } | {
69
+ } | ErrorItem;
70
+ export type ErrorItem = {
46
71
  kind: "error";
47
72
  line: number;
48
73
  source: string;
@@ -59,6 +84,10 @@ export type CellModel = {
59
84
  key?: Key;
60
85
  /** Words written on the bar (barmark's grammar 2.6), each before slot `at`. */
61
86
  words?: Words[];
87
+ /** A bar where the part rests, in a system (barmark's grammar 7.4). */
88
+ tacet?: true;
89
+ /** `%` on a tutti line whose parts each copy a different bar: drawn as `%`, its slots empty (barmark's grammar 7.4). */
90
+ simile?: true;
62
91
  } | {
63
92
  kind: "fermata";
64
93
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barmark/render",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "Render barmark chord charts as a clean, accessible bar grid: HTML with Declarative Shadow DOM, or the <barmark-chart> web component.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -47,7 +47,7 @@
47
47
  "url": "https://github.com/kevinpeckham/barmark/issues"
48
48
  },
49
49
  "dependencies": {
50
- "barmark": "^0.12.0"
50
+ "barmark": "^0.14.0"
51
51
  },
52
52
  "scripts": {
53
53
  "build": "rm -rf dist && tsc -p tsconfig.build.json",