UNPKG

screenplay-js

Version:

A modern Typescript, Foutain screenplay parser. Convert Final Draft (.fdx) files to Fountain, and then parse Fountain markdown to HTML.

369 lines (323 loc) 10.9 kB
import { tokenizer } from './tokenizer'; import { sections } from './sections'; import { v4 as uuid } from 'uuid'; import { IParserOptions, IScriptJSON } from '../interfaces'; /** * It's strongly recommended to use Normal because it comes closest to the 1-minute-per-page rule * relied upon by readers, development people, and (in particular) production staff. * * Reference: https://kb.finaldraft.com/s/article/How-many-lines-per-page-does-Final-Draft-write-and-what-are-my-line-spacing-options */ const LINES_PER_PAGE = { none: null, loose: 47, normal: 54, tight: 58, very_tight: 64, } const defaultOptions: IParserOptions = { paginate: true, lines_per_page: "loose", script_html: false, script_html_array: false, notes: true, draft_date: true, boneyard: true, tokens: false }; class Parser { options: IParserOptions; constructor(options: IParserOptions = defaultOptions) { this.options = options } /** * Parse a screenplay written in .fountain and output meta results * for the screenplay. * * @param { String } script - Text of the screenplay * @param { Object } _options - Options to pass into the parse function */ parse(script: string, _options: IParserOptions = this.options) { if (_options === undefined || _options === null) { _options = this.options } // Default options let options = { ...this.options, ..._options }; let tokens = tokenizer.tokenize(script), title_page_html: any[] = [], script_html: any[] = []; const output: IScriptJSON = { title: '', credit: '', authors: [], source: '', date: '', contact: '', copyright: '', scenes: [], title_page_html: '', script_pages: [], script_pages_html: [], }; let dialogueCounter = 0; for (let token of tokens) { token.text = this.lexer(token.text); switch (token.type) { case 'title': title_page_html.push('<h1>' + token.text + '</h1>'); output.title = token.text.replace('<br />', ' ').replace(/<(?:.|\n)*?>/g, ''); break; case 'credit': title_page_html.push('<p class="credit">' + token.text + '</p>'); output.credit = token.text; break; case 'author': title_page_html.push('<p class="authors">' + token.text + '</p>'); if (output.authors) { output.authors.push(token.text) } break; case 'authors': title_page_html.push('<p class="authors">' + token.text + '</p>'); if (output.authors) { output.authors = output.authors.concat(token.text.replace('<br />', "\n").replace(', ', ',').split(/[\n,]/)); } break; case 'source': title_page_html.push('<p class="source">' + token.text + '</p>'); output.source = (token.text); break; case 'notes': if (options.notes) { title_page_html.push('<p class="notes">' + token.text + '</p>'); output.notes = token.text; } break; case 'draft_date': if (options.draft_date) { title_page_html.push('<p class="draft-date">' + token.text + '</p>'); output.draft_date = token.text; } break; case 'date': title_page_html.push('<p class="date">' + token.text + '</p>'); output.date = token.text; break; case 'contact': title_page_html.push('<p class="contact">' + token.text + '</p>'); output.contact = token.text; break; case 'copyright': title_page_html.push('<p class="copyright">' + token.text + '</p>'); output.copyright = token.text; break; case 'scene_heading': script_html.push(`<h6 ${token.scene_number || token.scene_number === 0 ? 'id="scene-heading--' + token.scene_number + '"' : null} class="scene-heading" data-scene-heading-index="${token.scene_number}">${token.text}</h6>`); if (output.scenes) { output.scenes.push(token.text); } break; case 'transition': script_html.push('<p class="transition">' + token.text + '</p>'); break; case 'dual_dialogue_begin': script_html.push(`<div class="dual-dialogue" data-dialogue-index="${dialogueCounter}">`); dialogueCounter += 1; break; case 'dialogue_begin': script_html.push(`<div class="dialogue${token.dual ? ' ' + token.dual : ''}" data-dialogue-index="${dialogueCounter}">`); dialogueCounter += 1; break; case 'character': script_html.push('<p class="character">' + token.text.replace(/^@/, '') + '</p>'); break; case 'parenthetical': script_html.push('<p class="parenthetical">' + token.text + '</p>'); break; case 'dialogue': script_html.push('<p>' + token.text + '</p>'); break; case 'dialogue_end': script_html.push('</div>'); break; case 'dual_dialogue_end': script_html.push('</div>'); break; case 'section': script_html.push('<p class="section" data-depth="' + token.depth + '">' + token.text + '</p>'); break; case 'synopsis': script_html.push('<p class="synopsis">' + token.text + '</p>'); break; case 'note': if (options.notes) { script_html.push('<!-- ' + token.text + ' -->'); } break; case 'boneyard_begin': if (options.boneyard) { script_html.push('<!-- '); } break; case 'boneyard_end': if (options.boneyard) { script_html.push(' -->'); } break; case 'lyrics': script_html.push('<p class="lyrics">' + token.text + '</p>'); break; case 'action': script_html.push('<p class="action">' + token.text + '</p>'); break; case 'centered': script_html.push('<p class="centered">' + token.text + '</p>'); break; case 'page_break': script_html.push('<hr class="page-break" />'); break; case 'line_break': script_html.push('<br />'); break; } } output.title_page_html = title_page_html.join(''); if (options.script_html) { output.script_html = script_html.join(''); } if (options.script_html_array) { output.script_html_array = script_html; } if (options.paginate) { const { pages, pages_html } = this.paginate(script_html, options.lines_per_page) output.script_pages = pages output.script_pages_html = pages_html } if (options.tokens) { output.tokens = tokens; }; return output; } /** * Loosely paginate parsed screenplays. New pages will occur when a page * break is identified in the script. */ paginate(script_html: string[], lpp: string = this.options.lines_per_page): { pages: any, pages_html: any } { const lines_per_page = LINES_PER_PAGE[lpp]; let pages: any[] = []; let pages_html: any[] = []; let page: number = 0; let isOpenDiv: boolean = false; let addNewPage: boolean = false; let addDialogueChunk: boolean = false; let dialogueChunk: string = ''; for (let i = 0; i < script_html.length; i++) { const line = script_html[i]; /** * If page break, increment page count, and * push onto a new page. */ if (line.includes('page-break')) { page += 1; // Don't add the page-break html style continue; } /** * Add pagination based on the lines per page count */ if (isOpenDiv === false && addNewPage) { page += 1; addNewPage = false; } if (line.includes('<div ') && line !== '</div>') { isOpenDiv = true; // Begin dialogue chunk if (line.includes('class="dialogue') || addDialogueChunk) { addDialogueChunk = true; } } else if (line === '</div>') { isOpenDiv = false; // Close dialogue chunk if (addDialogueChunk) { addDialogueChunk = false; dialogueChunk += line; } } /** * Toggle new page flag */ if (i !== 0 && i % lines_per_page === 0) { addNewPage = true; } /** * Create a default empty array for page */ if (!pages[page]) { pages[page] = []; } // Add html to current page index pages[page].push(line); /** * Create a default empty array for pages_html */ if (!pages_html[page]) { pages_html[page] = []; } // Add dialogue chunked html to current page index if (dialogueChunk && !addDialogueChunk) { pages_html[page].push(dialogueChunk); dialogueChunk = ''; } else if (addDialogueChunk) { dialogueChunk += line; } else { pages_html[page].push(line); } } /** * For each page, join the html into a string */ for (let i = 0; i < pages.length; i++) { pages[i] = { _id: uuid(), html: pages[i].join("") }; } return { pages, pages_html }; } /** * Match and replace string elements with appropriate * inline CSS styles. * * @param { String } s */ lexer(s: string): string | undefined | null { if (!s) { return; } const inline = { note: '<!-- $1 -->', line_break: '<br />', bold_italic_underline: '<strong><em><span style="text-decoration:underline !important">$2</span></em></strong>', bold_underline: '<strong><span style="text-decoration:underline !important">$2</span></strong>', italic_underline: '<em><span style="text-decoration:underline !important">$1</span></em>', bold_italic: '<strong><em>$2</em></strong>', bold: '<strong>$2</strong>', italic: '<em>$2</em>', underline: '<span style="text-decoration:underline !important">$2</span>' }; let styles = ['bold_italic_underline', 'bold_underline', 'italic_underline', 'bold_italic', 'bold', 'italic', 'underline'], style, match; s = s.replace(sections.note_inline, inline.note) .replace(/\\\*/g, '[star]') .replace(/\\_/g, '[underline]') .replace(/\n/g, inline.line_break); for (const i in styles) { style = styles[i]; match = sections[style]; if (match.test(s)) { s = s.replace(match, inline[style]); } } return s.replace(/\[star\]/g, '*').replace(/\[underline\]/g, '_').trim(); } }; const FountainParser = new Parser() export { FountainParser }