UNPKG

@sahabaplus/mushaf-engine

Version:

TypeScript implementation of a Quran Mushaf navigation engine

380 lines (379 loc) 16.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.BaseMushafEngine = void 0; const mushaf_1 = require("../mushaf"); const navigation_1 = require("../navigation"); /** * Basic implementation of the Mushaf navigation engine */ class BaseMushafEngine { /** * Create a new BaseMushafEngine with the given Mushaf * * @param mushaf - The Mushaf to navigate through */ constructor(mushaf) { this.mushaf = mushaf; this.quranMetadata = mushaf_1.QuranMetadata.fromMushaf(mushaf); } /** * Create a navigator with the given settings and direction */ createNavigator(settings, direction) { return new navigation_1.VersesNavigator(this.mushaf, this.quranMetadata, settings, direction); } /** * Get metadata about a specific Sura * * @param suraNumber - Sura number (1-114) * @returns Information about the specified Sura, or undefined if not found */ getSuraInfo(suraNumber) { return this.quranMetadata.getSuraInfo(suraNumber); } /** * Find the next verse from a given verse in the specified direction * * @param from - Verse position to start from * @param direction - Direction to navigate * @param settings - Navigation settings * @returns The next verse or undefined if at the end */ nextVerse(from, direction, settings) { const navigator = this.createNavigator(settings, direction); navigator.resetPosition(from); return navigator.nextVerse(); } /** * Find the previous verse from a given verse in the specified direction * * @param from - Verse position to start from * @param direction - Direction to navigate * @param settings - Navigation settings * @returns The previous verse or undefined if at the start */ previousVerse(from, direction, settings) { const navigator = this.createNavigator(settings, direction); navigator.resetPosition(from); return navigator.previousVerse(); } /** * Check if a verse position is out of the current navigation bounds */ isOutOfBounds(verse, direction, settings) { const navigator = this.createNavigator(settings, direction); return navigator.isOutOfBounds(verse); } /** * Get the effective start of the navigation range for the given direction and settings */ getStartBound(direction, settings) { const navigator = this.createNavigator(settings, direction); return navigator.getStartBound(); } /** * Get the effective end of the navigation range for the given direction and settings */ getEndBound(direction, settings) { const navigator = this.createNavigator(settings, direction); return navigator.getEndBound(); } /** * Check if navigation direction is wrong for given start and end positions. * * This is a thin wrapper around `VersesNavigator.isWrongDirection` so that * consumers of the engine can reuse the same ordering rules. */ isWrongDirection(start, end, direction) { return navigation_1.VersesNavigator.isWrongDirection(start, end, direction); } /** * Find a verse in the mushaf and return its location (Verse, page, index_of_verse) * * @param verse - Verse position to find * @returns The verse and its location, or undefined if not found */ findVerseLocation(verse) { const sura = this.quranMetadata.getSuraInfo(verse.getSura()); if (sura) { const pages = this.mushaf.pages; for (let i = sura.startPage; i <= sura.endPage; i++) { const page = pages[i - 1]; for (let j = 0; j < page.verses().length; j++) { const verseObj = page.verses()[j]; if (verseObj.sura === verse.getSura() && verseObj.number === verse.getVerse()) { return { verse: verseObj, pageNumber: page.number, verseIndexOnPage: j, }; } } } } return undefined; } /** * Resolve a (sura, verse) position to the full Verse object in the mushaf */ findVerse(position) { const result = this.findVerseLocation(position); return result?.verse; } /** * Resolve a (sura, verse) pair directly to a Verse object. */ resolvePosition(sura, verse) { return this.findVerse(new navigation_1.VersePosition(sura, verse)); } /** * Calculate the direct distance in lines between start and end verses * * This calculates the simple A→B distance without accounting for cycles. * When bounded navigation produces cycles, callers can use the `cycleDistance` * field from `NavigationResult` to compute total distance: * * ```text * total_distance ≈ (cycles_completed - 1) * cycle_distance + direct_distance * ``` * * where `direct_distance` is obtained from this method. * * @param start - Start verse position * @param end - End verse position * @param direction - Direction of navigation * @param settings - Navigation settings * @returns Direct distance in lines between the two verses * @throws Error if the verses are unreachable or not found */ calculateLines(start, end, direction, settings) { const isWrongDirection = navigation_1.VersesNavigator.isWrongDirection(start, end, direction); const navigator = this.createNavigator(settings, direction); const outOfBounds = navigator.isOutOfBounds(start); if (isWrongDirection && outOfBounds) { throw new Error("Invalid direction for verse ordering"); } const startVerseResult = this.findVerseLocation(start); const endVerseResult = this.findVerseLocation(end); if (!startVerseResult || !endVerseResult) { throw new Error("Verse not found"); } const startVerseObj = startVerseResult.verse; const endVerseObj = endVerseResult.verse; const resetResult = navigator.resetPosition(start); if (!resetResult) { throw new Error("Verse not found or out of bounds"); } // Calculate direct distance from start to end let directLines = 0.0; let maxIterations = 10000; // Prevent infinite loops let iterations = 0; while (true) { const verseLines = navigator.calculateVerseLines(navigator.currentVerse()); directLines += verseLines; if (navigator.currentVerse().sura === endVerseObj.sura && navigator.currentVerse().number === endVerseObj.number) { break; } // Check if nextVerse() returns undefined to prevent infinite loop const nextVerse = navigator.nextVerse(); if (!nextVerse) { // If we couldn't reach the end verse and we're not cycling, return an error // But if start is within bounds, allow cycling to try to reach the end if (outOfBounds || iterations >= maxIterations) { throw new Error("Invalid direction for verse ordering"); } // Reset to start bound to allow cycling const startBound = navigator.getStartBound(); navigator.resetPosition(startBound); iterations++; continue; } iterations++; if (iterations >= maxIterations) { throw new Error("Invalid direction for verse ordering"); } } // Round lines to 2 decimal places const lines = Math.round(directLines * 100) / 100; return lines; } /** * Calculate the lines taken by a verse including any sura headers, using the * same rules as the internal navigator. */ calculateVerseLines(verse, settings) { const navigator = this.createNavigator(settings, navigation_1.Direction.Downwards); return navigator.calculateVerseLines(verse); } /** * Determine which last of sura result to prefer * * @param lastOfSura - Existing last of sura result, if any * @param currentVerse - Current verse being processed * @param direction - Direction of navigation * @returns The preferred last of sura result */ preferLastOfSura(lastOfSura, currentVerse, direction) { if (lastOfSura && currentVerse.sura === lastOfSura.lastVerse.sura) { return lastOfSura; } const sura = this.quranMetadata.getSuraInfo(currentVerse.sura); if (!sura) { throw new Error(`Sura info not found for sura ${currentVerse.sura}`); } const lastVerseResult = this.findVerseLocation(new navigation_1.VersePosition(sura.number, sura.totalVerses)); if (!lastVerseResult) { throw new Error(`Last verse not found for sura ${sura.number}`); } const lastVerse = lastVerseResult.verse; const linesDistance = this.calculateLines(navigation_1.VersePosition.fromVerse(currentVerse), navigation_1.VersePosition.fromVerse(lastVerse), direction, navigation_1.NavigationSettings.builder().build()) - currentVerse.lines; if (lastOfSura && Math.abs(lastOfSura.linesDistance) + 3.0 < Math.abs(linesDistance)) { // Added 3 to prefer the new last return lastOfSura; } else { return new navigation_1.LastVerseResult(linesDistance, lastVerse); } } /** * Determine which last of page result to prefer * * @param lastOfPage - Existing last of page result, if any * @param currentVerse - Current verse being processed * @param direction - Direction of navigation * @returns The preferred last of page result */ preferLastOfPage(lastOfPage, currentVerse, direction) { if (lastOfPage && currentVerse.equals(lastOfPage.lastVerse)) { return lastOfPage; } const verseInfo = this.findVerseLocation(navigation_1.VersePosition.fromVerse(currentVerse)); if (!verseInfo) { throw new Error(`Verse info not found for sura ${currentVerse.sura}, verse ${currentVerse.number}`); } const page = this.mushaf.getPage(verseInfo.pageNumber); if (!page) { throw new Error(`Page not found for page number ${verseInfo.pageNumber}`); } const newLastOfPage = page.verses()[page.verses().length - 1]; if (navigation_1.VersesNavigator.isWrongDirection(navigation_1.VersePosition.fromVerse(currentVerse), navigation_1.VersePosition.fromVerse(newLastOfPage), direction)) { return lastOfPage; } const linesDistance = this.calculateLines(navigation_1.VersePosition.fromVerse(currentVerse), navigation_1.VersePosition.fromVerse(newLastOfPage), direction, navigation_1.NavigationSettings.builder().build()); if (lastOfPage && Math.abs(lastOfPage.linesDistance) + 1.0 < Math.abs(linesDistance)) { // Added 1 to prefer the new last return lastOfPage; } else { return new navigation_1.LastVerseResult(linesDistance, newLastOfPage); } } /** * Navigate from a verse by a specified number of lines in the given direction * * @param params - Navigation parameters * @returns Navigation result containing the verse at the destination after navigation */ navigate(params) { const { lines, from, direction, settings } = params; // Validate inputs if (lines < 0) { throw new Error("Lines should be positive or zero"); } // If no lines to navigate, find and return the current verse if (lines === 0) { const verseResult = this.findVerseLocation(from); if (verseResult) { return navigation_1.NavigationResult.newNormal(verseResult.verse, 0); } throw new Error("Verse Not Found!"); } // Find starting position const verseResult = this.findVerseLocation(from); if (!verseResult) { throw new Error("Verse Not Found!"); } let remainingLines = lines; const navigator = this.createNavigator(settings, direction); const resetResult = navigator.resetPosition(from); if (!resetResult) { throw new Error("Verse Not Found or Out of Bounds!"); } let overflow; let previousVerse = navigator.currentVerse(); let lastOfPage; let lastOfSura; // Track cycles (passes through initial verse) and boundary crossings const initialVerse = navigator.currentVerse(); let cyclesCompleted = 0; let crossedBoundaries = false; let hasMoved = false; // Track cycle distance (lines accumulated in the current cycle) let linesInCurrentCycle = 0.0; let cycleDistance = 0.0; // Set when first cycle completes while (true) { const currentVerse = navigator.currentVerse(); // We looped twice and stuck at the same verse if (previousVerse.sura === currentVerse.sura && previousVerse.number === currentVerse.number && Math.abs(lines - remainingLines) > Number.EPSILON) { break; } // Track cycles: count passes through the initial verse (after at least one move) // Note: When a cycle is detected, we continue the loop and count this verse's lines // This ensures consistency with calculateLines cycle distance calculation if (hasMoved && currentVerse.sura === initialVerse.sura && currentVerse.number === initialVerse.number) { cyclesCompleted += 1; if (cycleDistance === 0.0) { // Capture cycle distance on first cycle completion cycleDistance = linesInCurrentCycle; } linesInCurrentCycle = 0.0; // Reset for next cycle } const currentSuraInfo = this.quranMetadata.getSuraInfo(currentVerse.sura); if (!currentSuraInfo) throw new Error(`Sura info not found for sura ${currentVerse.sura}`); const verseLines = navigator.calculateVerseLines(currentVerse); // Accumulate lines for cycle distance tracking linesInCurrentCycle += verseLines; const diff = Math.round((remainingLines - verseLines) * 100) / 100; if (currentVerse.isLastOfPage()) { lastOfPage = new navigation_1.LastVerseResult(-1.0 * diff, currentVerse); } if (currentSuraInfo.totalVerses === currentVerse.number) { lastOfSura = new navigation_1.LastVerseResult(-1.0 * diff, currentVerse); } if (diff < 0.0) { overflow = new navigation_1.OverflowResult(-1.0 * diff, currentVerse); break; } remainingLines = diff; previousVerse = currentVerse; if (remainingLines > Number.EPSILON) { // Check return value - break if boundary reached const nextVerse = navigator.nextVerse(); if (!nextVerse) { break; } if (navigator.crossedBoundaries) { crossedBoundaries = true; } hasMoved = true; } else { break; } } const finalLastOfSura = this.preferLastOfSura(lastOfSura, previousVerse, direction); const finalLastOfPage = this.preferLastOfPage(lastOfPage, previousVerse, direction); const cycleInfo = new navigation_1.CycleInfo(cyclesCompleted, crossedBoundaries, cycleDistance); return new navigation_1.NavigationResult(previousVerse, overflow, finalLastOfPage, finalLastOfSura, lines - remainingLines, cycleInfo); } } exports.BaseMushafEngine = BaseMushafEngine;