From 72b30fad805f62301c0c5ef4e4a0aa3db0f280b1 Mon Sep 17 00:00:00 2001 From: Will Estes Date: Sun, 23 Aug 2026 06:30:23 -0400 Subject: [PATCH] =?UTF-8?q?Add=20flexa-mark=20(=E2=80=A1)=20boundary=20com?= =?UTF-8?q?putation=20for=20antiphon/psalm=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01UCvvgFLrhXNVFUThz6kanw --- src/hours/antiphon.ts | 126 +++++++++++++++++++++++++++++++++++ tests/hours/antiphon.test.ts | 100 +++++++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 tests/hours/antiphon.test.ts diff --git a/src/hours/antiphon.ts b/src/hours/antiphon.ts index d3e6160..0f125c5 100644 --- a/src/hours/antiphon.ts +++ b/src/hours/antiphon.ts @@ -1,5 +1,6 @@ import type { DayWinner } from '../calendar/types'; import { isAtLeast } from '../calendar/commemorations'; +import type { ResolvedVerse, ResolvedText } from './types'; export interface SplitAntiphon { incipit: string; @@ -40,3 +41,128 @@ export function splitAntiphon(text: string): SplitAntiphon { export function isDoubleOrHigher(winner: DayWinner): boolean { 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 }; +} diff --git a/tests/hours/antiphon.test.ts b/tests/hours/antiphon.test.ts new file mode 100644 index 0000000..a7b5e94 --- /dev/null +++ b/tests/hours/antiphon.test.ts @@ -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: ‡'); + }); +});