'use strict'; // Shared by the scanner and publisher. Dimensions identify strip layout, not // language or the reading order of a printed page. RTL is only a default. const KEY = 'x-reading-direction'; const aliases = { rtl: 'rtl', ltr: 'ltr', ttb: 'ttb', vertical: 'ttb', webtoon: 'ttb' }; const normalizeDirection = value => aliases[String(value || '').trim().toLowerCase()] || null; function manualDirection(meta = {}) { const override = normalizeDirection(meta.directionOverride); if (override) return override; const value = normalizeDirection(meta.direction); if (!value) return null; const record = meta[KEY]; // Legacy plain directions are ambiguous: never assume a human didn't set one. // Editing a generated value without its provenance is also a manual override. if (!record || record.version !== 1 || record.value !== value || !['detected', 'fallback'].includes(record.source)) return value; return null; } function detectVerticalStrip(pages = []) { const dimensions = pages.map(p => { const rotated = p.r === 90 || p.r === 270; return { w: Number(rotated ? p.h : p.w), h: Number(rotated ? p.w : p.h) }; }).filter(p => Number.isFinite(p.w) && Number.isFinite(p.h) && p.w > 0 && p.h > 0); const tall = dimensions.filter(p => p.h / p.w >= 4); const widths = tall.map(p => p.w).sort((a, b) => a - b); const middle = widths.length ? widths[Math.floor(widths.length / 2)] : 0; const consistent = tall.filter(p => Math.abs(p.w - middle) <= middle * .25).length; const evidence = { pages: pages.length, knownPages: dimensions.length, tallPages: tall.length, consistentTallPages: consistent, medianStripWidth: middle }; // At least three coherent strips, >=75% of known pages, and >=75% known // dimensions. A landscape credits card may coexist with the vertical body. const detected = tall.length >= 3 && dimensions.length >= pages.length * .75 && tall.length >= dimensions.length * .75 && consistent >= tall.length * .75; return detected ? { value: 'ttb', source: 'detected', evidence } : null; } function fallbackDirection() { return { direction: 'rtl', [KEY]: { version: 1, value: 'rtl', source: 'fallback' } }; } // Scopes are chapter -> book -> manga (or existing -> incoming when publishing). // Any explicit choice beats inference; a more specific explicit choice wins. function resolveReadingDirection(scopes = [], pages = []) { for (let i = 0; i < scopes.length; i++) { const value = manualDirection(scopes[i]); if (value) return { value, source: 'manual', scope: i }; } const detected = detectVerticalStrip(pages); if (detected) return detected; for (let i = 0; i < scopes.length; i++) { const meta = scopes[i] || {}, record = meta[KEY]; if (record?.version === 1 && record.source === 'detected' && record.value === normalizeDirection(meta.direction) && record.value === 'ttb') { return { value: 'ttb', source: 'detected', scope: i, evidence: record.evidence }; } } return { value: 'rtl', source: 'fallback' }; } function directionFields(scopes, pages) { const { value, source, evidence } = resolveReadingDirection(scopes, pages); return { direction: value, ...(source === 'manual' && scopes.some(m => normalizeDirection(m?.directionOverride)) ? { directionOverride: value } : {}), [KEY]: { version: 1, value, source, ...(evidence ? { evidence } : {}) } }; } module.exports = { KEY, normalizeDirection, manualDirection, detectVerticalStrip, fallbackDirection, resolveReadingDirection, directionFields };