Add flexa-mark (‡) boundary computation for antiphon/psalm text

Ports Divinum Officium's getantcross/depunct (horas.pl): finds where a
displayed antiphon's words stop matching a psalm's first verse, so the
mark can later show where the antiphon leaves off and the psalm's own
text resumes. Not yet wired into any hour builder.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UCvvgFLrhXNVFUThz6kanw
This commit is contained in:
2026-08-23 06:30:23 -04:00
parent 9fb9cb5e24
commit 72b30fad80
2 changed files with 226 additions and 0 deletions
+126
View File
@@ -1,5 +1,6 @@
import type { DayWinner } from '../calendar/types'; import type { DayWinner } from '../calendar/types';
import { isAtLeast } from '../calendar/commemorations'; import { isAtLeast } from '../calendar/commemorations';
import type { ResolvedVerse, ResolvedText } from './types';
export interface SplitAntiphon { export interface SplitAntiphon {
incipit: string; incipit: string;
@@ -40,3 +41,128 @@ export function splitAntiphon(text: string): SplitAntiphon {
export function isDoubleOrHigher(winner: DayWinner): boolean { export function isDoubleOrHigher(winner: DayWinner): boolean {
return winner.kind === 'sanctoral' && isAtLeast(winner.rank, 'duplex'); return winner.kind === 'sanctoral' && isAtLeast(winner.rank, 'duplex');
} }
/**
* Normalizes a word for flexa-boundary comparison only (never used for
* display text): strips chant/sentence punctuation and folds accented Latin
* vowels and digraphs to their plain form, mirroring Divinum Officium's own
* `depunct` (see `getantcross` in the reference engine's horas.pl).
*/
function depunctFold(word: string): string {
return word
.replace(/[.,:;?!"'*()]/g, '')
.toLowerCase()
.replace(/[áà]/g, 'a')
.replace(/[éè]/g, 'e')
.replace(/[íì]/g, 'i')
.replace(/[óòöõ]/g, 'o')
.replace(/[úùüû]/g, 'u')
.replace(/[æǽ]/g, 'ae')
.replace(/œ/g, 'oe')
.replace(/j/g, 'i');
}
/**
* Finds where, within a psalm's first verse, the words already said as the
* opening antiphon (in whichever form — incipit or full — is actually being
* displayed) leave off and the rest of the verse resumes. This is the flexa
* mark's boundary: ported from Divinum Officium's `getantcross`/`depunct`
* (horas.pl), which computes this at render time rather than storing it as
* content, since it depends on which antiphon form the day's rank selects.
*
* Returns the character offset into `verseText` to insert the mark at, the
* literal `'full'` when the antiphon's words consume the entire verse (the
* mark then belongs at the start of the *next* verse instead — dangling it
* at the end of this one would be meaningless), or `null` when there's no
* word-for-word match (including when the antiphon is longer than the
* verse, per the reference engine's own rule) and so no mark should appear.
*/
export function computeFlexaBoundary(verseText: string, antiphonText: string): number | 'full' | null {
const verseWords = [...verseText.matchAll(/\S+/g)];
const antWords = antiphonText.split(/\s+/).filter(Boolean);
let pind = 0;
let aind = 0;
while (aind < antWords.length && pind < verseWords.length) {
const verseWord = verseWords[pind]![0];
pind++;
const item1 = depunctFold(verseWord);
if (!item1) continue;
const antWord = antWords[aind]!;
aind++;
const item2 = depunctFold(antWord);
if (!item2) {
pind--;
continue;
}
if (item1 !== item2) return null;
}
if (aind < antWords.length) return null; // antiphon is longer than the verse
while (pind < verseWords.length && !depunctFold(verseWords[pind]![0])) pind++;
if (pind >= verseWords.length) return 'full';
return verseWords[pind]!.index!;
}
export interface FlexaMarked {
verses: ResolvedVerse[];
antiphon: ResolvedText | undefined;
}
/**
* Applies the flexa boundary (see `computeFlexaBoundary`) to a psalm's
* verses, per language, based on the antiphon actually being displayed for
* it — and, so the same boundary reads consistently at both ends of it, also
* appends the mark to the end of the displayed antiphon text itself (the
* reference engine only ever marks the psalm side — `antetpsalm` in its
* `specials/psalmi.pl` pushes the antiphon's own display line before ever
* computing the psalm-side boundary — but showing the mark at the close of
* the antiphon too, not just where it resumes in the psalm, is this
* project's own deliberate addition, by direct instruction).
*
* A no-op whenever there's no antiphon (only the first psalm in a set
* carries one — see each hour's `resolvePart`) or no verses to mark.
*/
export function applyFlexaMark(verses: ResolvedVerse[], antiphon: ResolvedText | undefined): FlexaMarked {
const first = verses[0];
if (!antiphon || !first) return { verses, antiphon };
const second = verses[1];
let firstText = first.text;
let secondText = second?.text;
let antiphonText = antiphon.text;
for (const lang of Object.keys(antiphon.text)) {
const rawAntText = antiphon.text[lang];
if (!rawAntText) continue;
// splitNamedAntiphon bakes an "Ant. " display label directly into the
// text (same convention as "V."/"R." on versicles) — strip it before
// comparing, since it's never part of the psalm's own opening words.
const antText = rawAntText.replace(/^Ant\.\s+/, '');
const verseText = first.text[lang];
if (!antText || !verseText) continue;
const boundary = computeFlexaBoundary(verseText, antText);
if (boundary === null) continue;
antiphonText = { ...antiphonText, [lang]: `${rawAntText}` };
if (boundary === 'full') {
const nextText = second?.text[lang];
if (nextText) secondText = { ...secondText, [lang]: `${nextText}` };
continue;
}
firstText = { ...firstText, [lang]: `${verseText.slice(0, boundary)}${verseText.slice(boundary)}` };
}
if (firstText === first.text && secondText === second?.text && antiphonText === antiphon.text) {
return { verses, antiphon };
}
const updated = [...verses];
updated[0] = { ...first, text: firstText };
if (second && secondText) updated[1] = { ...second, text: secondText };
const updatedAntiphon = antiphonText === antiphon.text ? antiphon : { ...antiphon, text: antiphonText };
return { verses: updated, antiphon: updatedAntiphon };
}
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import { applyFlexaMark, computeFlexaBoundary } from '../../src/hours/antiphon';
import type { ResolvedText, ResolvedVerse } from '../../src/hours/types';
describe('computeFlexaBoundary', () => {
it('finds the boundary after an exact word-for-word prefix match', () => {
const boundary = computeFlexaBoundary(
'The king rejoices in thy strength, O Lord; * and in thy salvation he shall rejoice exceedingly.',
'The king rejoices',
);
expect(boundary).not.toBeNull();
expect(boundary).not.toBe('full');
const text = 'The king rejoices in thy strength, O Lord; * and in thy salvation he shall rejoice exceedingly.';
expect(text.slice(boundary as number)).toBe('in thy strength, O Lord; * and in thy salvation he shall rejoice exceedingly.');
});
it('matches accented Latin words against their unaccented/depunctuated form', () => {
const boundary = computeFlexaBoundary('Dómine, in virtúte tua lætábitur rex: * et super salutáre tuum exsultábit veheménter.', 'Dómine');
expect(boundary).not.toBeNull();
expect(boundary).not.toBe('full');
});
it('returns null when the antiphon is longer than the verse', () => {
const boundary = computeFlexaBoundary('Short verse text here.', 'Short verse text here and then some more words');
expect(boundary).toBeNull();
});
it('returns null when the words diverge', () => {
const boundary = computeFlexaBoundary('Dixit Dominus Domino meo.', 'Laudate pueri Dominum');
expect(boundary).toBeNull();
});
it("returns 'full' when the antiphon consumes the entire verse", () => {
const boundary = computeFlexaBoundary('Dixit Dominus Domino meo.', 'Dixit Dominus Domino meo.');
expect(boundary).toBe('full');
});
it('skips over trailing punctuation-only tokens before the boundary', () => {
// A stray standalone punctuation token between the matched words and
// the next real word shouldn't become the insertion point itself.
const text = 'Alpha beta : gamma delta.';
const boundary = computeFlexaBoundary(text, 'Alpha beta');
expect(boundary).not.toBeNull();
expect(boundary).not.toBe('full');
expect(text.slice(boundary as number)).toBe('gamma delta.');
});
});
describe('applyFlexaMark', () => {
const verse = (n: number, la: string, en: string): ResolvedVerse => ({
n,
text: { la, en },
status: { la: 'verified', en: 'verified' },
});
const antiphon = (la: string, en: string): ResolvedText => ({
text: { la, en },
status: { la: 'verified', en: 'verified' },
});
it('inserts the flexa mark into the first verse at the matched boundary, per language', () => {
const verses = [verse(2, 'Dómine, in virtúte tua lætábitur rex: * et super salutáre tuum exsultábit veheménter.', 'The king rejoices in thy strength, O Lord; * and in thy salvation he shall rejoice exceedingly.')];
const result = applyFlexaMark(verses, antiphon('Ant. Dómine', 'Ant. The king rejoices'));
expect(result.verses[0]!.text.la).toBe('Dómine, ‡ in virtúte tua lætábitur rex: * et super salutáre tuum exsultábit veheménter.');
expect(result.verses[0]!.text.en).toBe('The king rejoices ‡ in thy strength, O Lord; * and in thy salvation he shall rejoice exceedingly.');
});
it('also appends the mark to the end of the displayed antiphon text, in both languages', () => {
const verses = [verse(2, 'Dómine, in virtúte tua lætábitur rex: * et super salutáre tuum exsultábit veheménter.', 'The king rejoices in thy strength, O Lord; * and in thy salvation he shall rejoice exceedingly.')];
const result = applyFlexaMark(verses, antiphon('Ant. Dómine', 'Ant. The king rejoices'));
expect(result.antiphon?.text.la).toBe('Ant. Dómine ‡');
expect(result.antiphon?.text.en).toBe('Ant. The king rejoices ‡');
});
it('is a no-op when no antiphon is given', () => {
const verses = [verse(2, 'Dómine, in virtúte tua lætábitur rex.', 'The king rejoices in thy strength.')];
const result = applyFlexaMark(verses, undefined);
expect(result.verses).toBe(verses);
expect(result.antiphon).toBeUndefined();
});
it('is a no-op when the antiphon does not share opening words with the verse', () => {
const verses = [verse(1, 'Beátus vir qui non ábiit in consílio impiórum.', 'Blessed is the man who hath not walked in the counsel of the ungodly.')];
const theAntiphon = antiphon('Ant. Fidélia', 'Ant. Faithful');
const result = applyFlexaMark(verses, theAntiphon);
expect(result.verses).toEqual(verses);
expect(result.antiphon).toBe(theAntiphon);
});
it('moves the mark to the start of the second verse when the antiphon matches the entire first verse', () => {
const verses = [
verse(2, 'Dixit Dóminus Dómino meo:', 'The Lord said to my Lord:'),
verse(3, 'Sede a dextris meis.', 'Sit thou at my right hand.'),
];
const result = applyFlexaMark(verses, antiphon('Ant. Dixit Dóminus Dómino meo:', 'Ant. The Lord said to my Lord:'));
expect(result.verses[0]!.text.la).toBe('Dixit Dóminus Dómino meo:');
expect(result.verses[1]!.text.la).toBe('‡ Sede a dextris meis.');
expect(result.verses[1]!.text.en).toBe('‡ Sit thou at my right hand.');
expect(result.antiphon?.text.la).toBe('Ant. Dixit Dóminus Dómino meo: ‡');
});
});