// parser.js — Convierte un archivo .fdx (XML) en un JSON estructurado. const TRAMOYA_VERSION = '1.0'; const KNOWN_TYPES = new Set([ 'Scene Heading', 'Action', 'Character', 'Parenthetical', 'Dialogue', 'Transition', 'Shot', 'General' ]); export function parseFdx(xmlText) { const parser = new DOMParser(); const doc = parser.parseFromString(xmlText, 'application/xml'); const parserError = doc.querySelector('parsererror'); if (parserError) { throw new Error('El archivo no es un XML válido. Asegúrate de que es un .fdx generado por Final Draft.'); } const root = doc.querySelector('FinalDraft'); if (!root) { throw new Error('No se ha encontrado el elemento . ¿Es un archivo .fdx auténtico?'); } const titlePage = parseTitlePage(doc); const paragraphs = Array.from(doc.querySelectorAll('Content > Paragraph')); const scenes = buildScenes(paragraphs); fillStartPages(scenes); const characters = collectCharacters(scenes); const stats = computeStats(scenes, paragraphs); return { tramoya_version: TRAMOYA_VERSION, source_format: 'fdx', generated_at: new Date().toISOString(), title_page: titlePage, scenes, characters, stats }; } function parseTitlePage(doc) { const tp = doc.querySelector('TitlePage'); if (!tp) { return { title: null, author: null, contact: null, draft_date: null, raw: '' }; } const lines = []; tp.querySelectorAll('Paragraph').forEach(p => { const text = collectText(p).trim(); if (text) lines.push(text); }); const raw = lines.join('\n'); const findAfter = (labels) => { for (let i = 0; i < lines.length; i++) { const trimmed = lines[i].trim(); const stripped = trimmed.toLowerCase().replace(/[:.\s]+$/, ''); for (const label of labels) { // Línea exacta = label (p.ej. "Por", "By"): cogemos la siguiente. if (stripped === label) { if (lines[i + 1]) return lines[i + 1].trim(); continue; } // Línea empieza con label + separador (".", ":", "-", "—", "–") + contenido. const re = new RegExp(`^${escapeRegex(label)}\\s*[.:\\-—–]\\s*(.+)$`, 'i'); const m = trimmed.match(re); if (m) return m[1].trim(); } } return null; }; // Limpia comillas envolventes («"MIGUEL"» → «MIGUEL»). FDX a veces las // guarda literalmente en . const stripQuotes = (s) => { if (!s) return s; return s.trim().replace(/^["“”'`]+|["“”'`]+$/g, '').trim(); }; // Detector de fecha por patrón (DD/MM/YY, DD-MM-YYYY, etc.) cuando no hay // label explícito antes de la fecha. const dateRegex = /\b\d{1,2}[\/\-.]\d{1,2}[\/\-.]\d{2,4}\b/; const findDateInLines = () => { for (const line of lines) { if (dateRegex.test(line)) return line.trim(); } return null; }; // Detector de contacto: bloque al final con teléfono o email. Captura // hasta 3 líneas previas asumiendo dirección/nombre. const phoneRegex = /\+?\d[\d\s\-().]{6,}/; const emailRegex = /\S+@\S+\.\S+/; const findContact = () => { let idx = -1; for (let i = lines.length - 1; i >= 0; i--) { if (emailRegex.test(lines[i]) || phoneRegex.test(lines[i])) { idx = i; break; } } if (idx < 0) return null; const start = Math.max(0, idx - 3); return lines.slice(start, idx + 1).join('\n'); }; const title = stripQuotes(lines[0] || null); const author = findAfter([ 'por', 'by', 'written by', 'escrito por', 'autor', 'author', 'guion', 'guión' ]); const contact = findAfter(['contact', 'contacto']) || findContact(); const draftDate = findAfter(['draft date', 'date', 'fecha', 'ver', 'version', 'versión']) || findDateInLines(); return { title, author, contact, draft_date: draftDate, raw }; } function collectText(node) { const texts = []; node.querySelectorAll('Text').forEach(t => texts.push(t.textContent || '')); return texts.join(''); } function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } // Forward-fill: Final Draft omite Page="1" en la primera escena. Si al menos // una escena tiene Page asignado, asumimos que la primera empieza en 1 y // rellenamos huecos heredando de la escena anterior. function fillStartPages(scenes) { if (!scenes.length) return; const anyPage = scenes.some(s => Number.isFinite(s.start_page)); if (!anyPage) return; // FD nunca paginó: no inventamos números. if (!Number.isFinite(scenes[0].start_page)) { scenes[0].start_page = 1; } for (let i = 1; i < scenes.length; i++) { if (!Number.isFinite(scenes[i].start_page)) { scenes[i].start_page = scenes[i - 1].start_page; } } } function buildScenes(paragraphs) { const scenes = []; let current = null; let sceneNumber = 0; const flushDialogueBuffer = (buffer) => { if (!buffer || !current) return; current.elements.push(buffer); }; let dialogueBuffer = null; for (const p of paragraphs) { const type = p.getAttribute('Type') || 'General'; const text = collectText(p); if (type === 'Scene Heading') { flushDialogueBuffer(dialogueBuffer); dialogueBuffer = null; sceneNumber += 1; const heading = text.trim(); const parsed = parseSceneHeading(heading); const props = p.querySelector('SceneProperties'); const pageAttr = props?.getAttribute('Page'); const startPage = pageAttr ? parseInt(pageAttr, 10) : NaN; current = { scene_number: sceneNumber, heading, interior_exterior: parsed.interior_exterior, location: parsed.location, time_of_day: parsed.time_of_day, start_page: Number.isFinite(startPage) ? startPage : null, elements: [] }; scenes.push(current); continue; } if (!current) { // Texto antes de la primera escena: ignorar para mantener la estructura limpia. continue; } if (type === 'Character') { flushDialogueBuffer(dialogueBuffer); dialogueBuffer = { type: 'character', name: text.trim(), parenthetical: null, dialogue: '' }; continue; } if (type === 'Parenthetical') { const clean = text.trim().replace(/^\(/, '').replace(/\)$/, ''); if (dialogueBuffer) { dialogueBuffer.parenthetical = clean; } else { // Parenthetical huérfano: tratarlo como acción. current.elements.push({ type: 'action', text: text.trim() }); } continue; } if (type === 'Dialogue') { if (dialogueBuffer) { dialogueBuffer.dialogue = dialogueBuffer.dialogue ? dialogueBuffer.dialogue + ' ' + text.trim() : text.trim(); } else { // Diálogo huérfano: guardarlo como fragmento sin personaje. current.elements.push({ type: 'character', name: null, parenthetical: null, dialogue: text.trim() }); } continue; } // Cualquier otro tipo cierra el buffer de diálogo en curso. flushDialogueBuffer(dialogueBuffer); dialogueBuffer = null; if (type === 'Action' || type === 'General') { current.elements.push({ type: 'action', text: text.trim() }); continue; } if (type === 'Transition') { current.elements.push({ type: 'transition', text: text.trim() }); continue; } if (type === 'Shot') { current.elements.push({ type: 'shot', text: text.trim() }); continue; } // Tipo desconocido: preservar tal cual. current.elements.push({ type: 'unknown', unknown_type: type, text: text.trim() }); } // Volcar último buffer pendiente. flushDialogueBuffer(dialogueBuffer); return scenes; } function parseSceneHeading(heading) { if (!heading) { return { interior_exterior: null, location: null, time_of_day: null }; } // Patrones habituales: INT./EXT./INT-EXT./EXT-INT./INT/EXT const prefixMatch = heading.match(/^(INT\.?\/EXT\.?|EXT\.?\/INT\.?|INT-EXT\.?|EXT-INT\.?|INT\.?|EXT\.?)\s*[-.\s]\s*(.*)$/i); if (!prefixMatch) { return { interior_exterior: null, location: null, time_of_day: null }; } const ie = prefixMatch[1] .toUpperCase() .replace(/\./g, '') .replace(/\s+/g, ''); const rest = prefixMatch[2].trim(); // Separar ubicación de momento del día por el último guion. const lastDash = Math.max(rest.lastIndexOf(' - '), rest.lastIndexOf(' – '), rest.lastIndexOf(' — ')); let location = rest; let timeOfDay = null; if (lastDash !== -1) { location = rest.slice(0, lastDash).trim(); timeOfDay = rest.slice(lastDash + 3).trim(); } return { interior_exterior: ie || null, location: location || null, time_of_day: timeOfDay || null }; } function collectCharacters(scenes) { const set = new Set(); for (const scene of scenes) { for (const el of scene.elements) { if (el.type === 'character' && el.name) { set.add(normalizeCharacterName(el.name)); } } } return Array.from(set).sort(); } function normalizeCharacterName(raw) { // Quitar anotaciones tipo "(O.S.)", "(V.O.)", "(CONT'D)" para agrupar mejor. return raw.replace(/\s*\([^)]*\)\s*$/g, '').trim(); } function computeStats(scenes, paragraphs) { let interior = 0; let exterior = 0; let day = 0; let night = 0; let dialogueCount = 0; let maxPage = 0; for (const scene of scenes) { const ie = scene.interior_exterior || ''; if (ie.startsWith('INT')) interior += 1; if (ie.startsWith('EXT')) exterior += 1; const tod = (scene.time_of_day || '').toUpperCase(); if (/(DÍA|DIA|DAY|MAÑANA|MANANA|MORNING|TARDE|AFTERNOON)/.test(tod)) day += 1; if (/(NOCHE|NIGHT|MADRUGADA)/.test(tod)) night += 1; if (Number.isFinite(scene.start_page) && scene.start_page > maxPage) { maxPage = scene.start_page; } for (const el of scene.elements) { if (el.type === 'character' && el.dialogue) dialogueCount += 1; } } // Páginas reales: máxima Page declarada en SceneProperties del FDX. // Si Final Draft no las ha generado (sin repaginar), `pages` queda null. const pages = maxPage > 0 ? maxPage : null; return { scene_count: scenes.length, interior_count: interior, exterior_count: exterior, day_count: day, night_count: night, character_count: collectCharacters(scenes).length, dialogue_count: dialogueCount, pages }; }