@barmark/render 0.10.0 → 0.12.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,48 @@ package uses [semantic versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.12.0] - 2026-11-03
10
+
11
+ Needs `barmark` 0.14.1.
12
+
13
+ ### Changed
14
+
15
+ - **Breaking:** the element's one-part attribute is now `show-part`
16
+ (property `showPart`), not `part`. HTML keeps `part` on every element
17
+ for `::part()` styling, so a page styling `<barmark-chart part="…">`
18
+ got a one-part view by accident, and `el.part` could not return the
19
+ label. `renderChart`'s option stays `part`.
20
+
21
+ ### Fixed
22
+
23
+ - A system shows and speaks what its rows share once, on its first drawn
24
+ row: the bar number, "repeat start", the ending's number and ×N. Every
25
+ row still draws its repeat bar lines. A system whose first part's line
26
+ has an error numbers the next row.
27
+ - Tacet rows carry `bm-tacet` in a part's own view too, and a tacet bar
28
+ cut in a `->` replay is marked and spoken as cut.
29
+ - The "no part labelled X" warning is about the whole chart: its line is
30
+ 0, and the diagnostics list shows it without a line number.
31
+
32
+ ## [0.11.0] - 2026-11-03
33
+
34
+ Needs `barmark` 0.14.0.
35
+
36
+ ### Added
37
+
38
+ - **Several parts** (barmark 0.14.0, grammar 7.4): a system draws as
39
+ stacked rows, one per declared part in score order, sharing the
40
+ section's columns, with the part's label in the gutter and one bar
41
+ number. A part tacet there gets an empty row marked "tacet"; a tutti
42
+ line stays one row. The header lists the parts. The model gains
43
+ `parts`, a `system` item (its `rows`, each a row or an error) and
44
+ `tacet` on a bar. A tutti bar the parts play differently (`%` or a
45
+ leading `/` after a system) is drawn as written, `%` included, with
46
+ `simile` on its cell, rather than as one part's chords.
47
+ - **One part's view**: the `part` option and attribute draw only the
48
+ part with that label, as a single-part chart, with "tacet" where it
49
+ rests. An undeclared label draws the whole chart with a warning.
50
+
9
51
  ## [0.10.0] - 2026-11-03
10
52
 
11
53
  ### Added
@@ -275,7 +317,9 @@ that needs no script or as the `<barmark-chart>` web component. Requires
275
317
  zoom without sideways scrolling, the element renders and re-renders, and
276
318
  a screen reader hears "bar 9: D". Run them with `bun run test:browser`.
277
319
 
278
- [Unreleased]: https://github.com/kevinpeckham/barmark/compare/render-v0.10.0...HEAD
320
+ [Unreleased]: https://github.com/kevinpeckham/barmark/compare/render-v0.12.0...HEAD
321
+ [0.12.0]: https://github.com/kevinpeckham/barmark/compare/render-v0.11.0...render-v0.12.0
322
+ [0.11.0]: https://github.com/kevinpeckham/barmark/compare/render-v0.10.0...render-v0.11.0
279
323
  [0.10.0]: https://github.com/kevinpeckham/barmark/compare/render-v0.9.1...render-v0.10.0
280
324
  [0.9.1]: https://github.com/kevinpeckham/barmark/compare/render-v0.9.0...render-v0.9.1
281
325
  [0.9.0]: https://github.com/kevinpeckham/barmark/compare/render-v0.8.0...render-v0.9.0
package/README.md CHANGED
@@ -48,7 +48,9 @@ The element renders from its `text` property, its `src` URL, or a child
48
48
  whenever one changes; it fetches a `src` once, and renders once however
49
49
  many properties and attributes change together. A chart rendered on the
50
50
  server by `renderChart` keeps its music until it has a source of its own,
51
- and still follows `theme` and `bar-numbers`.
51
+ and still follows `theme`, `bar-numbers` and `wrap`; a change to
52
+ `numerals`, `rehearsal-letters` or `show-part` needs the chart's text, so
53
+ it waits for a source.
52
54
 
53
55
  For a live editor, set `text`:
54
56
 
@@ -77,6 +79,7 @@ the element loads, with `<style data-barmark-font>` holding
77
79
  | `rehearsalLetters` | `rehearsal-letters` (`rehearsalLetters`) | `false` | boxed A, B, C… on each section as played |
78
80
  | `numerals` | `numerals` (`numerals`) | none | `"nashville"` (6m⁷) or `"roman"` (vi⁷): each chord as its degree in its bar's key |
79
81
  | `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 |
82
+ | `part` | `show-part` (`showPart`) | none | draw only the part with this label, as one player reads it: tutti bars, its own bars, and "tacet" where it rests. The attribute isn't `part`, which HTML keeps for `::part()` styling |
80
83
  | `diagnostics` | | `false` | list the chart's errors and warnings below it |
81
84
  | `fontUrl` | | none | emit Edwin's `@font-face` rule before the chart |
82
85
  | (the `text` argument) | (`text`) | `null` | the chart to draw; wins over `src` and the script child |
@@ -185,6 +188,14 @@ section shows the numbers it was written with. A line with an error shows as wri
185
188
  with its message. `layoutChart(text)` returns all of this as data, if
186
189
  you want to draw it yourself.
187
190
 
191
+ A chart of several parts (barmark's grammar 7.4) draws each system as
192
+ stacked rows, one per part in score order, with the part's label in the
193
+ gutter and one bar number on the top row. A part tacet in a system gets
194
+ an empty row that says "tacet" in its first bar, so every system keeps its
195
+ shape; a tutti line is one row. The header lists the parts' names, and
196
+ each part row is announced by name to a screen reader. On a screen too
197
+ narrow for a section, its rows wrap one by one, as any row does.
198
+
188
199
  ## License
189
200
 
190
201
  MIT. Edwin is © MuseScore Limited, under the SIL Open Font License 1.1.
package/dist/element.js CHANGED
@@ -10,12 +10,15 @@ 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
+ // Not `part`: that global attribute names an element for ::part() styling.
14
+ const part = el.getAttribute("show-part");
13
15
  return {
14
16
  theme: THEMES.find((t) => t === theme) ?? "auto",
15
17
  barNumbers: el.getAttribute("bar-numbers") !== "off",
16
18
  rehearsalLetters: el.hasAttribute("rehearsal-letters"),
17
19
  wrap: el.getAttribute("wrap") !== "off",
18
20
  ...(numerals ? { numerals } : {}),
21
+ ...(part ? { part } : {}),
19
22
  };
20
23
  }
21
24
  /** What a chart draws: its `text` while that is a string, else `src`, else its script child. */
@@ -56,6 +59,7 @@ function defineElement() {
56
59
  "rehearsal-letters",
57
60
  "numerals",
58
61
  "wrap",
62
+ "show-part",
59
63
  ];
60
64
  #observer = new MutationObserver(() => this.#schedule());
61
65
  #renders = 0;
@@ -136,6 +140,16 @@ function defineElement() {
136
140
  else
137
141
  this.setAttribute("numerals", v);
138
142
  }
143
+ /** The label of the part to draw on its own, or null for the whole chart. */
144
+ get showPart() {
145
+ return this.getAttribute("show-part");
146
+ }
147
+ set showPart(v) {
148
+ if (v === null)
149
+ this.removeAttribute("show-part");
150
+ else
151
+ this.setAttribute("show-part", v);
152
+ }
139
153
  /** Every change in one task (an upgrade sets each attribute in turn) renders once. */
140
154
  #schedule() {
141
155
  if (this.#scheduled)
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
@@ -7,7 +7,7 @@ import { printDirection, printPasses, } from "barmark";
7
7
  import { glyphs, NUMERALS, splitPitch, typesetChord, typesetNumeral, } from "./chord.js";
8
8
  import { escapeHtml } from "./escape.js";
9
9
  import { fontFaceCss } from "./font.js";
10
- import { layoutChart } from "./model.js";
10
+ import { isTacet as isTacetCell, layoutChart } from "./model.js";
11
11
  import { speakBar } from "./speech.js";
12
12
  import { FIT_STEPS, MIN_BAR, stylesheet } from "./styles.js";
13
13
  export function renderChart(text, options = {}) {
@@ -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 ? ` show-part="${escapeHtml(options.part)}"` : "",
25
26
  ].join("");
26
27
  }
27
28
  export const THEMES = ["auto", "light", "dark"];
@@ -37,20 +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 = [model.part ? escapeHtml(model.part.name) : "", metaText(model.header)]
52
- .filter(Boolean)
53
- .join(" · ");
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(" · ");
54
63
  if (!title && !meta)
55
64
  return "";
56
65
  return `<header class="bm-header">${title}${meta ? `<p class="bm-meta" part="meta">${meta}</p>` : ""}</header>`;
@@ -144,6 +153,8 @@ function itemHtml(item, columns, settings) {
144
153
  switch (item.kind) {
145
154
  case "row":
146
155
  return rowHtml(item, columns, settings);
156
+ case "system":
157
+ return systemHtml(item, columns, settings);
147
158
  case "text":
148
159
  return `<p class="bm-text${item.note ? " bm-note" : ""}" part="text">${escapeHtml(item.text)}</p>`;
149
160
  case "directive":
@@ -157,23 +168,65 @@ function errorHtml(source, messages) {
157
168
  const message = escapeHtml(messages.join("; "));
158
169
  return `<div class="bm-error" title="${message}"><code>${escapeHtml(source)}</code><span class="bm-sr">Error: ${message}</span></div>`;
159
170
  }
160
- function rowHtml(row, columns, settings) {
161
- const number = settings.barNumbers
162
- ? `<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
+ // The first drawn row carries what the system shares: its number, ×N, the ending's number.
175
+ const lead = item.rows.findIndex((p) => p.row.kind === "row");
176
+ const rows = item.rows.map((p, i) => p.row.kind === "error"
177
+ ? errorHtml(p.row.source, p.row.messages)
178
+ : rowHtml(p.row, columns, settings, { label: p.label, name: p.name, first: i === lead }));
179
+ return `<div class="bm-system" role="group" aria-label="${names}">${rows.join("")}</div>`;
180
+ }
181
+ /** The left column: the bar number, and in a system the part's label. */
182
+ function gutterHtml(row, settings, part) {
183
+ if (!settings.gutter)
184
+ return "";
185
+ const number = settings.barNumbers && (!part || part.first) ? (row.barNumber ?? "") : "";
186
+ const label = part
187
+ ? `<span class="bm-part-label" part="part-label">${escapeHtml(part.label)}</span>`
163
188
  : "";
189
+ return `<span class="bm-bar-number" part="bar-number" aria-hidden="true">${label}${number}</span>`;
190
+ }
191
+ /** A row's cells; a tacet row says "tacet" in its first bar. */
192
+ function rowCells(row, numerals, ending) {
164
193
  let n = row.barNumber;
165
- const label = row.ending ? endingLabel(row.ending) : null;
166
- const cells = row.cells.map((cell, i) => {
167
- const ending = i === 0 && label ? ` data-ending="${label.shown}"` : "";
168
- const html = cellHtml(cell, n, repeatMarks(row, i) + ending, settings.numerals);
194
+ const firstTacet = row.cells.findIndex(isTacetCell);
195
+ return row.cells.map((cell, i) => {
196
+ const marks = repeatMarks(row, i) + (i === 0 ? ending : "");
197
+ const html = isTacetCell(cell)
198
+ ? tacetHtml(cell, n, marks, i === firstTacet)
199
+ : cellHtml(cell, n, marks, numerals);
169
200
  if (n !== null && cell.kind === "slots")
170
201
  n++;
171
202
  return html;
172
203
  });
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 (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) {
219
+ const opens = leads(part) && (row.opens || typeof row.repeat === "number" || row.repeat === "open");
220
+ return sr(part ? escapeHtml(part.name) : "") + sr(opens ? "repeat start" : "") + sr(ending);
221
+ }
222
+ /** Whether a row says what its system shares: a row on its own, or a system's first drawn row. */
223
+ const leads = (part) => !part || part.first;
224
+ function rowHtml(row, columns, settings, part) {
225
+ const label = row.ending && leads(part) ? endingLabel(row.ending) : null;
226
+ const cells = rowCells(row, settings.numerals, label ? ` data-ending="${label.shown}"` : "");
173
227
  const empty = `<span class="bm-empty" aria-hidden="true"></span>`.repeat(Math.max(0, columns - row.cells.length));
174
- const opens = row.opens || typeof row.repeat === "number" || row.repeat === "open";
175
- const ending = label ? `<span class="bm-sr">${label.spoken}</span>` : "";
176
- 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>`;
228
+ const said = rowOpening(row, part, label?.spoken ?? "");
229
+ return `<div class="${rowClasses(row, part)}">${said}${gutterHtml(row, settings, part)}${cells.join("")}${empty}<span class="bm-repeat">${leads(part) ? rowRepeatHtml(row) : ""}</span></div>`;
177
230
  }
178
231
  /** An ending's passes as printed, "1.", "1.–3.", "1., 3.", and spoken, "ending for passes 1 to 3". */
179
232
  function endingLabel(passes) {
@@ -202,13 +255,25 @@ function cellHtml(cell, barNumber, marks, numerals) {
202
255
  const spoken = `<span class="bm-sr">${escapeHtml(speakBar(barNumber, cell, view !== null))}</span>`;
203
256
  if (cell.kind !== "slots")
204
257
  return betweenBarsHtml(cell, spoken, marks);
258
+ const change = cell.tempoChange ? ` data-tempo-change="${cell.tempoChange}"` : "";
259
+ const [body, end] = barBody(cell, view);
260
+ 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>`;
261
+ }
262
+ /** What a bar shows: its meter, its slots (or `%`), and "cut"; then the words after its last slot. */
263
+ function barBody(cell, view) {
205
264
  const meter = cell.meter
206
265
  ? `<span class="bm-meter"><span>${cell.meter.n}</span><span>${cell.meter.d}</span></span>`
207
266
  : "";
208
267
  const cut = cell.cut ? `<span class="bm-cut">cut</span>` : "";
209
- const change = cell.tempoChange ? ` data-tempo-change="${cell.tempoChange}"` : "";
210
- const [slots, end] = slotsWithWords(cell, view);
211
- 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>`;
268
+ const [written, end] = slotsWithWords(cell, view);
269
+ const slots = cell.simile ? `<span class="bm-simile">%</span>` : written.join("");
270
+ return [`${meter}${slots}${cut}`, end];
271
+ }
272
+ /** A bar where the part rests; the first of a row says so, in its body. */
273
+ function tacetHtml(cell, n, marks, first) {
274
+ const cut = cell.kind === "slots" && cell.cut;
275
+ const spoken = `<span class="bm-sr">bar ${n ?? ""}: tacet${cut ? ", cut on this pass" : ""}</span>`;
276
+ return `<div class="bm-bar bm-tacet-bar" part="bar"${marks}${cut ? " data-cut" : ""}>${spoken}<span class="bm-bar-body" aria-hidden="true">${first ? "tacet" : ""}</span></div>`;
212
277
  }
213
278
  /** A bar's slots, each under any words before it, and the words after the last slot. */
214
279
  function slotsWithWords(cell, view) {
@@ -319,5 +384,7 @@ function notesHtml(record) {
319
384
  function diagnosticsHtml(model) {
320
385
  if (!model.diagnostics.length)
321
386
  return "";
322
- return `<ul class="bm-diagnostics">${model.diagnostics.map((d) => `<li class="bm-${d.severity}">line ${d.line}: ${escapeHtml(d.message)}</li>`).join("")}</ul>`;
387
+ // Line 0: about the whole chart, not a line of it.
388
+ const at = (line) => (line > 0 ? `line ${line}: ` : "");
389
+ return `<ul class="bm-diagnostics">${model.diagnostics.map((d) => `<li class="bm-${d.severity}">${at(d.line)}${escapeHtml(d.message)}</li>`).join("")}</ul>`;
323
390
  }
package/dist/model.d.ts CHANGED
@@ -1,2 +1,4 @@
1
- import type { ChartModel, LayoutOptions } from "./types.js";
1
+ import type { CellModel, ChartModel, LayoutOptions } from "./types.js";
2
2
  export declare function layoutChart(text: string, options?: LayoutOptions): ChartModel;
3
+ /** A bar where the part rests, in a system or a part's own view (barmark's grammar 7.4). */
4
+ export declare const isTacet: (c: CellModel) => boolean;
package/dist/model.js CHANGED
@@ -4,9 +4,46 @@
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 0: the warning is about the chart, not one of its lines.
38
+ line: 0,
39
+ severity: "warning",
40
+ message: `no part labelled ${part}`,
41
+ token: part,
42
+ };
43
+ return { ...whole, diagnostics: [missing, ...whole.diagnostics] };
44
+ }
8
45
  export function layoutChart(text, options = {}) {
9
- const parse = parseChart(text);
46
+ const parse = parseFor(text, options.part);
10
47
  const { header, body } = splitHeader(parse.lines);
11
48
  const groups = groupLines(body);
12
49
  const ctx = {
@@ -14,17 +51,25 @@ export function layoutChart(text, options = {}) {
14
51
  source: text.split(/\r?\n/),
15
52
  errors: errorsByLine(parse),
16
53
  written: new Map(groups.flatMap((g) => (g.heading ? [[g.heading.line, g]] : []))),
54
+ bars: barsByLine(parse),
17
55
  };
18
56
  const blocks = groups.map((g) => layoutGroup(g, ctx));
19
57
  if (options.rehearsalLetters)
20
58
  letterBlocks(blocks);
21
- return { title: parse.title, part: parse.part, header, blocks, diagnostics: parse.diagnostics };
59
+ return {
60
+ title: parse.title,
61
+ part: parse.part,
62
+ parts: parse.parts,
63
+ header,
64
+ blocks,
65
+ diagnostics: parse.diagnostics,
66
+ };
22
67
  }
23
68
  /** Rehearsal letters mark position: every section occurrence, and leading bars, in play order. */
24
69
  function letterBlocks(blocks) {
25
70
  let n = 0;
26
71
  for (const b of blocks) {
27
- if (b.form === null && !b.items.some((i) => i.kind === "row"))
72
+ if (b.form === null && !b.items.some((i) => i.kind === "row" || i.kind === "system"))
28
73
  continue;
29
74
  b.rehearsal = letters(n++);
30
75
  }
@@ -114,34 +159,61 @@ function substituteText(items, own) {
114
159
  return next ? [next] : [];
115
160
  });
116
161
  // 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"));
162
+ const after = Math.max(out.findLastIndex((i) => i.kind === "row" || i.kind === "system"), out.findLastIndex((i) => i.kind === "text"));
118
163
  out.splice(after + 1, 0, ...queue);
119
164
  return out;
120
165
  }
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;
166
+ /** The row's last bar, marked cut. */
167
+ function cutRow(row) {
127
168
  const c = row.cells.findLastIndex((cell) => cell.kind === "slots");
128
169
  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));
170
+ return { ...row, cells };
171
+ }
172
+ const hasBars = (i) => (i.kind === "row" && lastSlots(i.cells) !== null) || i.kind === "system";
173
+ /** The parser cuts the last bar it would play; a fermata is not a bar. A system's every part loses it. */
174
+ function markCut(items) {
175
+ const r = items.findLastIndex(hasBars);
176
+ const item = items[r];
177
+ if (!item || (item.kind !== "row" && item.kind !== "system"))
178
+ return items;
179
+ const cut = item.kind === "row"
180
+ ? cutRow(item)
181
+ : {
182
+ ...item,
183
+ rows: item.rows.map((p) => (p.row.kind === "row" ? { ...p, row: cutRow(p.row) } : p)),
184
+ };
185
+ return items.map((it, i) => (i === r ? cut : it));
130
186
  }
187
+ /** How many cells an item's widest row has. */
188
+ const widthOf = (i) => i.kind === "row"
189
+ ? i.cells.length
190
+ : i.kind === "system"
191
+ ? Math.max(0, ...i.rows.map((p) => (p.row.kind === "row" ? p.row.cells.length : 0)))
192
+ : 0;
131
193
  function blockOf(h, occurrence, items) {
132
194
  return {
133
195
  heading: h?.name ?? null,
134
196
  repeat: h?.repeat ?? null,
135
197
  form: occurrence?.form ?? null,
136
198
  rehearsal: null,
137
- columns: Math.max(0, ...items.map((i) => (i.kind === "row" ? i.cells.length : 0))),
199
+ columns: Math.max(0, ...items.map(widthOf)),
138
200
  items,
139
201
  };
140
202
  }
141
203
  function layoutLines(lines, occurrence, ctx) {
142
204
  const items = [];
143
205
  let previous = null;
144
- for (const l of lines) {
206
+ for (const [i, l] of lines.entries()) {
207
+ const system = systemOf(l);
208
+ if (system !== null) {
209
+ // A system is laid out once, at its first line.
210
+ if (systemOf(lines[i - 1]) === system)
211
+ continue;
212
+ const item = layoutSystem(systemLines(lines, i), occurrence, previous, ctx);
213
+ previous = lastOfSystem(item) ?? previous;
214
+ items.push(item);
215
+ continue;
216
+ }
145
217
  if (ctx.errors.has(l.line)) {
146
218
  items.push(errorItem(l.line, ctx));
147
219
  continue;
@@ -155,10 +227,66 @@ function layoutLines(lines, occurrence, ctx) {
155
227
  }
156
228
  return items;
157
229
  }
230
+ const systemOf = (l) => (l?.kind === "bars" || l?.kind === "invalid") && l.system !== undefined ? l.system : null;
231
+ /** The lines of the system that starts at `i`. */
232
+ function systemLines(lines, i) {
233
+ const system = systemOf(lines[i]);
234
+ const out = [];
235
+ for (const l of lines.slice(i)) {
236
+ if (systemOf(l) !== system)
237
+ break;
238
+ out.push(l);
239
+ }
240
+ return out;
241
+ }
242
+ const lastOfSystem = (item) => {
243
+ const first = item.rows.find((p) => p.row.kind === "row" && !p.row.cells.some(isTacet))?.row;
244
+ return first?.kind === "row" ? lastSlots(first.cells) : null;
245
+ };
246
+ /** A bar where the part rests, in a system or a part's own view (barmark's grammar 7.4). */
247
+ export const isTacet = (c) => c.kind === "slots" && c.tacet === true;
248
+ /** A row per declared part, in score order: its own line, its error, or tacet bars. */
249
+ function layoutSystem(lines, occurrence, previous, ctx) {
250
+ const ref = lines.find((l) => l.kind === "bars" && !ctx.errors.has(l.line));
251
+ const reference = ref
252
+ ? layoutRow(ref, playedOn(ref.line, occurrence, ctx.bars), previous, ctx)
253
+ : null;
254
+ const rows = ctx.parse.parts.map((p) => {
255
+ const own = lines.find((l) => l.part === p.label);
256
+ const row = own && ctx.errors.has(own.line)
257
+ ? errorItem(own.line, ctx)
258
+ : own?.kind === "bars"
259
+ ? layoutRow(own, playedOn(own.line, occurrence, ctx.bars), previous, ctx)
260
+ : tacetRow(reference, lines[0]?.line ?? 0);
261
+ return { label: p.label ?? "", name: p.name, row };
262
+ });
263
+ return {
264
+ kind: "system",
265
+ line: lines[0]?.line ?? 0,
266
+ barNumber: reference?.barNumber ?? null,
267
+ rows,
268
+ };
269
+ }
270
+ const NO_ROW = {
271
+ kind: "row",
272
+ line: 0,
273
+ barNumber: null,
274
+ opens: false,
275
+ closes: false,
276
+ repeat: null,
277
+ ending: null,
278
+ cells: [],
279
+ };
280
+ /** The system's bars, with nothing played in them (barmark's grammar 7.4). */
281
+ function tacetRow(reference, line) {
282
+ const base = reference ?? NO_ROW;
283
+ const cells = base.cells.map((c) => c.kind === "slots" ? { kind: "slots", meter: c.meter, slots: [], cut: false, tacet: true } : c);
284
+ return { ...base, line, cells };
285
+ }
158
286
  function layoutLine(l, occurrence, previous, ctx) {
159
287
  switch (l.kind) {
160
288
  case "bars":
161
- return layoutRow(l, playedOn(l.line, occurrence, ctx.parse.bars), previous, ctx);
289
+ return layoutRow(l, playedOn(l.line, occurrence, ctx.bars), previous, ctx);
162
290
  case "text":
163
291
  return textItem(l.text);
164
292
  case "directive":
@@ -193,7 +321,7 @@ function playedOn(line, occurrence, bars) {
193
321
  function layoutRow(l, played, previous, ctx) {
194
322
  const cells = [];
195
323
  // 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);
324
+ const written = ctx.bars.filter((b) => b.line === l.line);
197
325
  let bar = 0;
198
326
  let prev = previous;
199
327
  for (const cell of l.cells) {
@@ -222,8 +350,16 @@ function layoutCell(cell, played, previous) {
222
350
  return { kind: "fermata" };
223
351
  if (cell.kind === "direction" || cell.kind === "rehearsal")
224
352
  return cell;
353
+ // Parts that play a tutti bar differently each read their own: draw it as written.
354
+ if (played?.perPart)
355
+ return withPlayed(asWritten(cell, previous), played);
225
356
  return withPlayed(layoutBar(cell, played, previous), played);
226
357
  }
358
+ /** A tutti bar as written: its own slots, holds as holds, or `%` itself. */
359
+ function asWritten(cell, previous) {
360
+ const bar = layoutBar(cell, undefined, previous);
361
+ return cell.kind === "simile" ? { ...bar, slots: [], simile: true } : bar;
362
+ }
227
363
  /** A bar or `%`, with the slots it plays. */
228
364
  function layoutBar(cell, played, previous) {
229
365
  const own = cell.kind === "slots" ? cell : null;
@@ -235,8 +371,11 @@ function layoutBar(cell, played, previous) {
235
371
  ...wordsOf(cell),
236
372
  };
237
373
  }
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 } : {};
374
+ /** The words written on a bar, and whether the part rests there, as fields to spread. */
375
+ const wordsOf = (cell) => ({
376
+ ...(cell.words && { words: cell.words }),
377
+ ...(cell.kind === "slots" && cell.tacet && { tacet: cell.tacet }),
378
+ });
240
379
  /** What the parser says about the bar as played: inside a rit. or accel., and its key. */
241
380
  function withPlayed(cell, played) {
242
381
  const change = played?.tempoChange?.kind;
package/dist/speech.js CHANGED
@@ -148,6 +148,9 @@ 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.simile)
153
+ return `${bar}: each part repeats its bar before`;
151
154
  const meter = cell.meter ? `, in ${cell.meter.n}/${cell.meter.d}` : "";
152
155
  const cut = cell.cut ? ", cut on this pass" : "";
153
156
  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,12 +1,16 @@
1
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;
8
- /** What `part:` names; null when the chart names none. */
10
+ /** What `part:` names in a single-part chart; null when the chart names none or several. */
9
11
  part: Part | null;
12
+ /** Every declared part, in score order (barmark's grammar 7.4). */
13
+ parts: Part[];
10
14
  /** Directives before the first heading, bar line or text line. */
11
15
  header: Directives;
12
16
  blocks: SectionBlock[];
@@ -24,6 +28,9 @@ export type SectionBlock = {
24
28
  columns: number;
25
29
  items: BlockItem[];
26
30
  };
31
+ export type RowItem = Extract<BlockItem, {
32
+ kind: "row";
33
+ }>;
27
34
  export type BlockItem = {
28
35
  kind: "row";
29
36
  line: number;
@@ -36,6 +43,21 @@ export type BlockItem = {
36
43
  ending: number[] | null;
37
44
  cells: CellModel[];
38
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
+ }
39
61
  /** `note` marks a `###` line, whose text is given without the marks. */
40
62
  | {
41
63
  kind: "text";
@@ -44,7 +66,8 @@ export type BlockItem = {
44
66
  } | {
45
67
  kind: "directive";
46
68
  set: Directives;
47
- } | {
69
+ } | ErrorItem;
70
+ export type ErrorItem = {
48
71
  kind: "error";
49
72
  line: number;
50
73
  source: string;
@@ -61,6 +84,10 @@ export type CellModel = {
61
84
  key?: Key;
62
85
  /** Words written on the bar (barmark's grammar 2.6), each before slot `at`. */
63
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;
64
91
  } | {
65
92
  kind: "fermata";
66
93
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barmark/render",
3
- "version": "0.10.0",
3
+ "version": "0.12.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.13.0"
50
+ "barmark": "^0.14.1"
51
51
  },
52
52
  "scripts": {
53
53
  "build": "rm -rf dist && tsc -p tsconfig.build.json",