From d9def2f8df15ffa728393284898871826744a821 Mon Sep 17 00:00:00 2001 From: Frank LENORMAND Date: Thu, 14 May 2020 12:47:45 +0300 Subject: [PATCH 1/2] Load settings from a configuration file This commit allows settings to be loaded from a configuration file. If one exists at `$XDG_CONFIG_HOME/screenplain/screenplain.cfg` (by default), it will be loaded. A new `-c`/`--config` command line flag allows loading a configuration file explicitly. Command line options take precedence over the settings loaded from the configuration file. The format of the file is INI, with no interpolations, options without values are allowed, and double-bracketed "sub-sections" (aesthetics only, the hierarchy of such sections in relation with "root-level" sections is not enforced). An example of a configuration file would be: ```ini [export] format: pdf [[pdf]] strong: no [[html]] base: no css ``` --- screenplain/config.py | 62 +++++++++++++++++++++++++++++++++++++++++++ screenplain/main.py | 43 +++++++++++++++++++++++++++--- 2 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 screenplain/config.py diff --git a/screenplain/config.py b/screenplain/config.py new file mode 100644 index 0000000..1a78a21 --- /dev/null +++ b/screenplain/config.py @@ -0,0 +1,62 @@ +import os +import re +import json +import configparser + + +class Defaults: + FILENAME_CONFIG = 'screenplain.cfg' + PATH_CONFIG = os.path.join( + os.getenv('XDG_CONFIG_HOME') or + os.path.join(os.getenv('HOME'), '.config'), + 'screenplain', FILENAME_CONFIG) + + +class ConfigurationFileError(Exception): + pass + + +class ConfigurationFile(configparser.ConfigParser): + def __init__(self, path=Defaults.PATH_CONFIG): + super().__init__(interpolation=None, + allow_no_value=True, + converters={ + 'list': self.__getlist, + }) + + # Allow brackets in section names + self.SECTCRE = re.compile( + r'^[ \t\r\f\v]*\[(?P
.+?)\][ \t\r\f\v]*$') + + # Initialize sections and their expected values + self.read_string(""" + [export] + format + + [[pdf]] + strong: no + font + + [[html]] + base: no + css + + [font] + """) + + try: + self.read(path) + except configparser.Error as e: + raise ConfigurationFileError( + 'unable to load configuration file: %s' % e) + + def __getlist(self, v): + try: + v = json.loads(v) + except json.JSONDecodeError as e: + raise ConfigurationFileError('unable to decode JSON value: %s' % e) + + if not isinstance(v, list): + raise ConfigurationFileError('value is not a list: %s' % type(v)) + + return v diff --git a/screenplain/main.py b/screenplain/main.py index 9f337f1..7bf3c73 100644 --- a/screenplain/main.py +++ b/screenplain/main.py @@ -4,11 +4,13 @@ # Licensed under the MIT license: # http://www.opensource.org/licenses/mit-license.php +import os import sys import codecs from optparse import OptionParser from screenplain.parsers import fountain +from screenplain.config import ConfigurationFile, ConfigurationFileError output_formats = ( 'fdx', 'html', 'pdf' @@ -32,6 +34,14 @@ def invalid_format(parser, message): def main(args): parser = OptionParser(usage=usage) + parser.add_option( + '-c', '--config', + metavar='CONFIG', + help=( + 'Path to the configuration file to load (superseeded by command ' + 'line options)' + ) + ) parser.add_option( '-f', '--format', dest='output_format', metavar='FORMAT', @@ -43,6 +53,7 @@ def main(args): parser.add_option( '--bare', action='store_true', + default=False, dest='bare', help=( 'For HTML output, only output the actual screenplay, ' @@ -60,6 +71,7 @@ def main(args): parser.add_option( '--strong', action='store_true', + default=False, dest='strong', help=( 'For PDF output, scene headings will appear ' @@ -72,8 +84,27 @@ def main(args): input_file = (len(args) > 0 and args[0] != '-') and args[0] or None output_file = (len(args) > 1 and args[1] != '-') and args[1] or None - format = options.output_format - if format is None and output_file: + try: + if options.config: + if not os.path.isfile(options.config): + sys.stderr.write('no such file: %s' % options.config) + return + config = ConfigurationFile(options.config) + else: + config = ConfigurationFile() + except ConfigurationFileError as e: + sys.stderr.write('error: %s' % e) + return + + if options.output_format: + config['export']['format'] = options.output_format + if options.css: + config['[html]']['css'] = options.css + config['[html]']['bare'] = str(options.bare) + config['[pdf]']['strong'] = str(options.strong) + + format = config['export']['format'] + if not format and output_file: if output_file.endswith('.fdx'): format = 'fdx' elif output_file.endswith('.html'): @@ -121,14 +152,18 @@ def main(args): from screenplain.export.fdx import to_fdx to_fdx(screenplay, output) elif format == 'html': + html_options = config['[html]'] from screenplain.export.html import convert convert( screenplay, output, - css_file=options.css, bare=options.bare + css_file=html_options['css'], + bare=html_options.getboolean('bare') ) elif format == 'pdf': + pdf_options = config['[pdf]'] from screenplain.export.pdf import to_pdf - to_pdf(screenplay, output, is_strong=options.strong) + to_pdf(config, screenplay, output, + is_strong=pdf_options.getboolean('strong')) finally: if output_file: output.close() From 2c3699a107186dbe52360a81c3a93dd713a086c6 Mon Sep 17 00:00:00 2001 From: Frank LENORMAND Date: Thu, 14 May 2020 12:50:13 +0300 Subject: [PATCH 2/2] Allow customising the font of the PDF output This commit allows the configuration file to declare custom fonts to be used, when exporting a PDF file. The functionality could probably be extended to HTML exports. Example that sets "Courier Prime" as default font: ```ini [export] format: pdf [[pdf]] strong: no font: courier_prime [font] [[courier_prime]] name: Courier Prime regular: ["Courier Prime", "courier-prime.ttf"] bold: ["Courier Prime Bold", "courier-prime-bold.ttf"] italic: ["Courier Prime Italic", "courier-prime-italic.ttf"] bold_italic: ["Courier Prime Bold Italic", "courier-prime-bold-italic.ttf"] ``` --- screenplain/export/pdf.py | 194 ++++++++++++++++++++++---------------- 1 file changed, 115 insertions(+), 79 deletions(-) diff --git a/screenplain/export/pdf.py b/screenplain/export/pdf.py index 6955616..ea641f5 100644 --- a/screenplain/export/pdf.py +++ b/screenplain/export/pdf.py @@ -23,6 +23,8 @@ from reportlab.lib.units import inch from reportlab.lib.styles import ParagraphStyle from reportlab.lib.enums import TA_CENTER, TA_RIGHT +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont from screenplain.types import ( Action, Dialog, DualDialog, Transition, Slug @@ -44,74 +46,80 @@ bottom_margin = page_height - top_margin - frame_height -default_style = ParagraphStyle( - 'default', - fontName='Courier', - fontSize=font_size, - leading=line_height, - spaceBefore=0, - spaceAfter=0, - leftIndent=0, - rightIndent=0, -) -centered_style = ParagraphStyle( - 'default-centered', default_style, - alignment=TA_CENTER, -) +class ParagraphStyles: + def __init__(self, font_name): + self.default_style = ParagraphStyle( + 'default', + fontName=font_name, + fontSize=font_size, + leading=line_height, + spaceBefore=0, + spaceAfter=0, + leftIndent=0, + rightIndent=0, + ) + self.centered_style = ParagraphStyle( + 'default-centered', self.default_style, + alignment=TA_CENTER, + ) -# Screenplay styles -character_style = ParagraphStyle( - 'character', default_style, - spaceBefore=line_height, - leftIndent=19 * character_width, - keepWithNext=1, -) -dialog_style = ParagraphStyle( - 'dialog', default_style, - leftIndent=9 * character_width, - rightIndent=frame_width - (45 * character_width), -) -parenthentical_style = ParagraphStyle( - 'parenthentical', default_style, - leftIndent=13 * character_width, - keepWithNext=1, -) -action_style = ParagraphStyle( - 'action', default_style, - spaceBefore=line_height, -) -centered_action_style = ParagraphStyle( - 'centered-action', action_style, - alignment=TA_CENTER, -) -slug_style = ParagraphStyle( - 'slug', default_style, - spaceBefore=line_height, - spaceAfter=line_height, - keepWithNext=1, -) -transition_style = ParagraphStyle( - 'transition', default_style, - spaceBefore=line_height, - spaceAfter=line_height, - alignment=TA_RIGHT, -) + # Screenplay styles + self.character_style = ParagraphStyle( + 'character', self.default_style, + spaceBefore=line_height, + leftIndent=19 * character_width, + keepWithNext=1, + ) + self.dialog_style = ParagraphStyle( + 'dialog', self.default_style, + leftIndent=9 * character_width, + rightIndent=frame_width - (45 * character_width), + ) + self.parenthentical_style = ParagraphStyle( + 'parenthentical', self.default_style, + leftIndent=13 * character_width, + keepWithNext=1, + ) + self.action_style = ParagraphStyle( + 'action', self.default_style, + spaceBefore=line_height, + ) + self.centered_action_style = ParagraphStyle( + 'centered-action', self.action_style, + alignment=TA_CENTER, + ) + self.slug_style = ParagraphStyle( + 'slug', self.default_style, + spaceBefore=line_height, + spaceAfter=line_height, + keepWithNext=1, + ) + self.transition_style = ParagraphStyle( + 'transition', self.default_style, + spaceBefore=line_height, + spaceAfter=line_height, + alignment=TA_RIGHT, + ) -# Title page styles -title_style = ParagraphStyle( - 'title', default_style, - fontSize=24, leading=36, - alignment=TA_CENTER, -) -contact_style = ParagraphStyle( - 'contact', default_style, - leftIndent=3.9 * inch, - rightIndent=0, -) + # Title page styles + self.title_style = ParagraphStyle( + 'title', self.default_style, + fontSize=24, leading=36, + alignment=TA_CENTER, + ) + self.contact_style = ParagraphStyle( + 'contact', self.default_style, + leftIndent=3.9 * inch, + rightIndent=0, + ) + + +styles = ParagraphStyles('Courier') class DocTemplate(BaseDocTemplate): - def __init__(self, *args, **kwargs): + def __init__(self, font_name, *args, **kwargs): + self.font_name = font_name self.has_title_page = kwargs.pop('has_title_page', False) frame = Frame( left_margin, bottom_margin, frame_width, frame_height, @@ -126,7 +134,7 @@ def __init__(self, *args, **kwargs): ) def handle_pageBegin(self): - self.canv.setFont('Courier', font_size, leading=line_height) + self.canv.setFont(self.font_name, font_size, leading=line_height) if self.has_title_page: page = self.page # self.page is 0 on first page else: @@ -157,12 +165,15 @@ def add_slug(story, para, style, is_strong): def add_dialog(story, dialog): - story.append(Paragraph(dialog.character.to_html(), character_style)) + global styles + story.append(Paragraph(dialog.character.to_html(), styles.character_style)) for parenthetical, line in dialog.blocks: if parenthetical: - story.append(Paragraph(line.to_html(), parenthentical_style)) + story.append(Paragraph(line.to_html(), + styles.parenthentical_style)) else: - story.append(Paragraph(line.to_html(), dialog_style)) + story.append(Paragraph(line.to_html(), + styles.dialog_style)) def add_dual_dialog(story, dual): @@ -198,25 +209,29 @@ def add_lines(story, attribute, style, space_before=0): total_height += height return space_before + total_height + global styles title_story = [] title_height = sum(( - add_lines(title_story, 'Title', title_style), + add_lines(title_story, 'Title', styles.title_style), add_lines( - title_story, 'Credit', centered_style, space_before=line_height + title_story, 'Credit', styles.centered_style, + space_before=line_height ), - add_lines(title_story, 'Author', centered_style), - add_lines(title_story, 'Authors', centered_style), - add_lines(title_story, 'Source', centered_style), + add_lines(title_story, 'Author', styles.centered_style), + add_lines(title_story, 'Authors', styles.centered_style), + add_lines(title_story, 'Source', styles.centered_style), )) lower_story = [] lower_height = sum(( - add_lines(lower_story, 'Draft date', default_style), + add_lines(lower_story, 'Draft date', styles.default_style), add_lines( - lower_story, 'Contact', contact_style, space_before=line_height + lower_story, 'Contact', styles.contact_style, + space_before=line_height ), add_lines( - lower_story, 'Copyright', centered_style, space_before=line_height + lower_story, 'Copyright', styles.centered_style, + space_before=line_height ), )) @@ -242,10 +257,29 @@ def add_lines(story, attribute, style, space_before=0): def to_pdf( - screenplay, output_filename, + config, screenplay, output_filename, template_constructor=DocTemplate, is_strong=False, ): + font_name = 'Courier' + + if config.has_option('[pdf]', 'font') and config['[pdf]']['font']: + section_name = '[%s]' % config['[pdf]']['font'] + if config.has_section(section_name): + font_name = config.get(section_name, 'name', fallback=section_name) + + for font_type in ['regular', 'bold', 'italic', 'bold_italic']: + font_attr = config.getlist(section_name, font_type) + if len(font_attr) != 2: + raise ValueError(('Invalid font attribute: %s is %s, ' + 'which must be a two-elements list') % + (font_type, font_attr)) + + pdfmetrics.registerFont(TTFont(*font_attr)) + + global styles + styles = ParagraphStyles(font_name) + story = get_title_page_story(screenplay) has_title_page = bool(story) @@ -257,12 +291,13 @@ def to_pdf( elif isinstance(para, Action): add_paragraph( story, para, - centered_action_style if para.centered else action_style + styles.centered_action_style if para.centered + else styles.action_style ) elif isinstance(para, Slug): - add_slug(story, para, slug_style, is_strong) + add_slug(story, para, styles.slug_style, is_strong) elif isinstance(para, Transition): - add_paragraph(story, para, transition_style) + add_paragraph(story, para, styles.transition_style) elif isinstance(para, types.PageBreak): story.append(platypus.PageBreak()) else: @@ -270,6 +305,7 @@ def to_pdf( pass doc = template_constructor( + font_name, output_filename, pagesize=(page_width, page_height), has_title_page=has_title_page