Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions playground/stores/formatter-store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Key } from '../../src';

import { APP_EVENTS } from './init-store';
import { Key } from '../../src';
import { PdfFormatter } from '../../src/pdf';
import { createStore } from './store';
import { formatterConfigExamples } from '../fixtures';
import { getMeasuredHtmlDefaultConfig } from '../../src/formatter/configuration/default_config_manager';
Expand All @@ -18,7 +18,6 @@ import {
ChordProFormatter,
ChordsOverWordsFormatter,
HtmlDivFormatter,
PdfFormatter,
} from '../../src/index';

// Define the available formatter types
Expand Down
6 changes: 5 additions & 1 deletion src/chord_sheet_serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,14 @@ class ChordSheetSerializer {
lyrics,
annotation,
eagerChord,
isRhythmSymbol || false,
isRhythmSymbol || this.isRhythmSymbol(chordString),
);
}

private isRhythmSymbol(chordString: string): boolean {
return chordString === '|' || chordString === '/';
}

parseTag(astComponent: SerializedTag): Tag {
const {
name,
Expand Down
27 changes: 19 additions & 8 deletions src/layout/engine/item_processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export class ItemProcessor {
const measurements = this.calculateMeasurements(renderedChords, lyrics, nextItem, lyricsOnly);

return {
item: new ChordLyricsPair(chords, lyrics),
item: new ChordLyricsPair(chords, lyrics, splitItem.annotation, null, splitItem.isRhythmSymbol),
width: measurements.totalWidth,
chordHeight: measurements.chordHeight,
};
Expand Down Expand Up @@ -275,11 +275,7 @@ export class ItemProcessor {
const firstLyrics = splitLines[0];
const secondLyrics = splitLines.slice(1).join(' ');
return [
{
item: new ChordLyricsPair(item.item.chords, firstLyrics),
width: this.measurer.measureTextWidth(firstLyrics, lyricsFont),
chordHeight: item.chordHeight,
},
this.createSplitMeasuredItem(item, firstLyrics, item.item.chords, item.item.isRhythmSymbol),
{
item: new ChordLyricsPair('', secondLyrics),
width: this.measurer.measureTextWidth(secondLyrics, lyricsFont),
Expand All @@ -288,6 +284,21 @@ export class ItemProcessor {
];
}

private createSplitMeasuredItem(
originalItem: MeasuredItem,
lyrics: string,
chords: string,
isRhythmSymbol: boolean,
): MeasuredItem {
const annotation = originalItem.item instanceof ChordLyricsPair ? originalItem.item.annotation : null;

return {
item: new ChordLyricsPair(chords, lyrics, annotation, null, isRhythmSymbol),
width: this.measurer.measureTextWidth(lyrics, this.config.fonts.lyrics),
chordHeight: originalItem.chordHeight,
};
}

/**
* Removes hyphens from lyrics for lyrics-only mode
*/
Expand Down Expand Up @@ -324,11 +335,11 @@ export class ItemProcessor {
}

if (index === 0 && lyricFragments.length === 1) {
items.push(new ChordLyricsPair(chords, fragment, annotation));
items.push(new ChordLyricsPair(chords, fragment, annotation, null, pair.isRhythmSymbol));
} else if (index === 0 && lyricFragments.length > 1) {
let commaAdjustedFragment = fragment;
commaAdjustedFragment += ',';
items.push(new ChordLyricsPair(chords, commaAdjustedFragment, annotation));
items.push(new ChordLyricsPair(chords, commaAdjustedFragment, annotation, null, pair.isRhythmSymbol));
}
});

Expand Down
177 changes: 162 additions & 15 deletions src/layout/engine/line_breaker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export class LineBreaker {
return this.handleNoSoftBreaks(items, availableWidth, line);
}

const { firstChunk, secondChunk } = this.splitAtBestSoftBreak(items, softBreakIndices, totalWidth);
const { firstChunk, secondChunk } = this.splitAtBestSoftBreak(items, softBreakIndices, totalWidth, availableWidth);

if (firstChunk.length === 0) {
return this.breakContent(secondChunk, availableWidth, line);
Expand Down Expand Up @@ -73,8 +73,10 @@ export class LineBreaker {
return this.handleOversizedFirstItem(items, availableWidth, line);
}

const firstChunk = items.slice(0, breakIndex);
const secondChunk = items.slice(breakIndex);
let firstChunk = items.slice(0, breakIndex);
let secondChunk = items.slice(breakIndex);

({ firstChunk, secondChunk } = this.avoidLeadingRhythmSymbol(firstChunk, secondChunk, availableWidth));

this.removeTrailingComma(firstChunk);

Expand Down Expand Up @@ -165,30 +167,175 @@ export class LineBreaker {
items: MeasuredItem[],
softBreakIndices: number[],
totalWidth: number,
availableWidth: number,
): { firstChunk: MeasuredItem[]; secondChunk: MeasuredItem[] } {
const bestBreak = this.findBestSoftBreak(items, softBreakIndices, totalWidth);
const softBreak = items[bestBreak.index];

let firstChunk = items.slice(0, bestBreak.index);
let secondChunk = items.slice(bestBreak.index + 1);

({ firstChunk, secondChunk } = this.avoidLeadingRhythmSymbol(
firstChunk,
secondChunk,
availableWidth,
softBreak,
));

this.removeTrailingComma(firstChunk);
this.capitalizeNextItem(secondChunk, secondChunk, 0);

return { firstChunk, secondChunk };
}

private findBestSoftBreak(items: MeasuredItem[], softBreakIndices: number[], totalWidth: number) {
const targetWidth = totalWidth / 2;
const breakOptions = softBreakIndices.map((idx) => ({
index: idx,
widthUpToBreak: this.getWidthUpToIndex(items, idx),
}));
const bestBreak = breakOptions.reduce((best, current) => {
const currentDistance = Math.abs(current.widthUpToBreak - targetWidth);
const bestDistance = Math.abs(best.widthUpToBreak - targetWidth);

if (currentDistance === bestDistance) {
return current.index > best.index ? current : best;
return breakOptions.reduce((best, current) => this.selectBetterSoftBreak(best, current, targetWidth));
}

private selectBetterSoftBreak<T extends { index: number; widthUpToBreak: number }>(
best: T,
current: T,
targetWidth: number,
): T {
const currentDistance = Math.abs(current.widthUpToBreak - targetWidth);
const bestDistance = Math.abs(best.widthUpToBreak - targetWidth);

if (currentDistance === bestDistance) {
return current.index > best.index ? current : best;
}

return currentDistance < bestDistance ? current : best;
}

/**
* Avoid wrapping a line so that the next visual line starts with a rhythm symbol.
*
* Prefer keeping the leading rhythm symbol(s) with the previous line, but only
* when the previous line still fits. If they do not fit, break earlier by
* moving the nearest preceding non-rhythm chord to the next line. Width checks
* are recalculated in the final line context so trailing chord-spacing is not
* counted for a line-ending rhythm symbol such as `|`.
*/
private avoidLeadingRhythmSymbol(
firstChunk: MeasuredItem[],
secondChunk: MeasuredItem[],
availableWidth: number,
separator: MeasuredItem | null = null,
): { firstChunk: MeasuredItem[]; secondChunk: MeasuredItem[] } {
if (!this.shouldAvoidLeadingRhythmSymbol(secondChunk)) {
return { firstChunk, secondChunk };
}

return this.tryMoveLeadingRhythmSymbolsToFirstChunk(firstChunk, secondChunk, availableWidth, separator) ||
this.movePrecedingChordToSecondChunk(firstChunk, secondChunk, separator);
}

private shouldAvoidLeadingRhythmSymbol(secondChunk: MeasuredItem[]): boolean {
const leadingRhythmSymbolCount = this.countLeadingRhythmSymbols(secondChunk);
return this.startsWithRhythmSymbol(secondChunk) &&
this.hasChordAfterLeadingRhythmSymbols(secondChunk, leadingRhythmSymbolCount);
}

private tryMoveLeadingRhythmSymbolsToFirstChunk(
firstChunk: MeasuredItem[],
secondChunk: MeasuredItem[],
availableWidth: number,
separator: MeasuredItem | null,
): { firstChunk: MeasuredItem[]; secondChunk: MeasuredItem[] } | null {
const leadingRhythmSymbolCount = this.countLeadingRhythmSymbols(secondChunk);
const separatorItems = separator ? [separator] : [];
const laterFirstChunk = [...firstChunk, ...separatorItems, ...secondChunk.slice(0, leadingRhythmSymbolCount)];
const laterSecondChunk = secondChunk.slice(leadingRhythmSymbolCount);

if (this.calculateLineContextWidth(laterFirstChunk) <= availableWidth) {
return { firstChunk: laterFirstChunk, secondChunk: laterSecondChunk };
}

return null;
}

private movePrecedingChordToSecondChunk(
firstChunk: MeasuredItem[],
secondChunk: MeasuredItem[],
separator: MeasuredItem | null,
): { firstChunk: MeasuredItem[]; secondChunk: MeasuredItem[] } {
const precedingChordIndex = this.findLastNonRhythmChordIndex(firstChunk);

if (precedingChordIndex <= 0) {
return { firstChunk, secondChunk };
}

return {
firstChunk: firstChunk.slice(0, precedingChordIndex),
secondChunk: [...firstChunk.slice(precedingChordIndex), ...this.separatorItems(separator), ...secondChunk],
};
}

private separatorItems(separator: MeasuredItem | null): MeasuredItem[] {
return separator ? [separator] : [];
}

private startsWithRhythmSymbol(items: MeasuredItem[]): boolean {
return this.isRhythmSymbolItem(items[0]);
}

private countLeadingRhythmSymbols(items: MeasuredItem[]): number {
let count = 0;

while (count < items.length && this.isRhythmSymbolItem(items[count])) {
count += 1;
}

return count;
}

private hasChordAfterLeadingRhythmSymbols(items: MeasuredItem[], leadingRhythmSymbolCount: number): boolean {
return this.isNonRhythmChordItem(items[leadingRhythmSymbolCount]);
}

private findLastNonRhythmChordIndex(items: MeasuredItem[]): number {
for (let index = items.length - 1; index >= 0; index -= 1) {
if (this.isNonRhythmChordItem(items[index])) {
return index;
}
}

return currentDistance < bestDistance ? current : best;
});
return -1;
}

const firstChunk = items.slice(0, bestBreak.index);
const secondChunk = items.slice(bestBreak.index + 1);
private isRhythmSymbolItem(item: MeasuredItem | undefined): boolean {
return item?.item instanceof ChordLyricsPair && item.item.isRhythmSymbol;
}

this.removeTrailingComma(firstChunk);
this.capitalizeNextItem(secondChunk, secondChunk, 0);
private isNonRhythmChordItem(item: MeasuredItem | undefined): boolean {
return item?.item instanceof ChordLyricsPair &&
!item.item.isRhythmSymbol &&
(item.item.chords || '').trim() !== '';
}

return { firstChunk, secondChunk };
private calculateLineContextWidth(items: MeasuredItem[]): number {
return items.reduce((width, item, index) => {
const nextItem = items[index + 1] || null;
return width + this.recalculateWidthForLineContext(item, nextItem);
}, 0);
}

private recalculateWidthForLineContext(item: MeasuredItem, nextItem: MeasuredItem | null): number {
if (item.item instanceof ChordLyricsPair) {
return this.recalculateChordLyricWidth(item, nextItem);
}

if (!nextItem && item.item instanceof SoftLineBreak) {
return 0;
}

return item.width;
}

private removeTrailingComma(items: MeasuredItem[]): void {
Expand Down
11 changes: 11 additions & 0 deletions test/layout/engine/item_processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,17 @@ describe('ItemProcessor', () => {
expect(result[0]).toEqual(pair);
});

it('preserves rhythm symbols when splitting or measuring chord-lyrics pairs', () => {
const { processor } = createProcessor();
const pair = new ChordLyricsPair('|', '', null, null, true);
const line = new Line();
line.addChordLyricsPair(pair);

const result = processor.measureLineItems(line);

expect((result[0].item as ChordLyricsPair).isRhythmSymbol).toBe(true);
});

it('returns pair when lyrics empty', () => {
const { processor } = createProcessor();
const pair = createChordLyricsPair('C', '');
Expand Down
Loading