Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
root = true

[*.fountain]
trim_trailing_whitespace = false
27 changes: 27 additions & 0 deletions .github/workflows/pre-commit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Pre-commit checks

on:
push:
branches: [ "master" ]
pull_request:

jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.13"]

steps:
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install setup uv
run: uv sync --frozen --dev
- name: Run pre-commit
uses: j178/prek-action@v1
16 changes: 8 additions & 8 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
@@ -1,30 +1,30 @@
name: Python package
name: Python build and tests

on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]

jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.13"]
python-version: ["3.12", "3.13"]

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "pip"
- name: Install uv
run: python -m pip install uv
uses: astral-sh/setup-uv@v7
- name: Install setup uv
run: uv sync --frozen --dev
- name: Test
run: bin/test
run: uv run pytest --doctest-modules -W error
- name: Command-line smoke test
run: |
uv run screenplain tests/files/simple.fountain /tmp/simple.pdf
29 changes: 29 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-added-large-files
- id: trailing-whitespace
exclude: \.fountain$
- id: no-commit-to-branch
args: [--branch, main]
- repo: local
hooks:
- id: ruff-format
name: ruff format
language: system
entry: uv run ruff format
pass_filenames: false
files: "\\.py$"
- id: ruff-check
name: ruff check
language: system
entry: uv run ruff check --fix
pass_filenames: false
files: "\\.py$"
- id: ty
name: ty
language: system
entry: uv run --no-sync ty check
files: "\\.pyi?$"
pass_filenames: false
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name = "screenplain"
version = "0.12.0"
description = "Convert text file to viewable screenplay."
readme = "README.md"
requires-python = ">=3.9"
requires-python = ">=3.12"
license = "MIT"
authors = [
{name = "Martin Vilcans", email = "screenplain@librador.com"}
Expand Down Expand Up @@ -41,7 +41,8 @@ dev = [
]

[tool.ruff]
src = ["./"]
src = ["."]
include = ["*.py"]

[tool.ruff.lint]
select = [
Expand Down
23 changes: 15 additions & 8 deletions screenplain/export/fdx.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,35 @@
# Licensed under the MIT license:
# http://www.opensource.org/licenses/mit-license.php

from typing import TextIO
from xml.sax.saxutils import escape

from screenplain.richstring import Bold, Italic, Underline
from screenplain.richstring import Bold, Italic, RichString, Style, Underline
from screenplain.types import (
Action,
Dialog,
DualDialog,
Screenplay,
Slug,
Transition,
)

style_names = {
style_names: dict[type[Style], str] = {
Bold: "Bold",
Italic: "Italic",
Underline: "Underline",
}


def _write_text_element(out, styles, text):
def _write_text_element(out: TextIO, styles: list[str], text: str) -> None:
style_value = "+".join(str(s) for s in styles)
if style_value == "":
out.write(f" <Text>{escape(text)}</Text>\n")
else:
out.write(f' <Text Style="{style_value}">{escape(text)}</Text>\n')


def write_text(out, rich, trailing_linebreak) -> None:
def write_text(out: TextIO, rich: RichString, trailing_linebreak: bool) -> None:
"""Writes <Text Style="..."> elements."""
for seg_no, segment in enumerate(rich.segments):
fdx_styles = [style_names[n] for n in segment.get_ordered_styles()]
Expand All @@ -38,7 +40,12 @@ def write_text(out, rich, trailing_linebreak) -> None:
_write_text_element(out, fdx_styles, segment.text)


def write_paragraph(out, para_type, lines, centered=False) -> None:
def write_paragraph(
out: TextIO,
para_type: str,
lines: list[RichString],
centered: bool = False,
) -> None:
if centered:
out.write(f' <Paragraph Alignment="Center" Type="{para_type}">\n')
else:
Expand All @@ -50,7 +57,7 @@ def write_paragraph(out, para_type, lines, centered=False) -> None:
out.write(" </Paragraph>\n")


def write_dialog(out, dialog) -> None:
def write_dialog(out: TextIO, dialog: Dialog) -> None:
write_paragraph(out, "Character", [dialog.character])
for parenthetical, line in dialog.blocks:
if parenthetical:
Expand All @@ -59,14 +66,14 @@ def write_dialog(out, dialog) -> None:
write_paragraph(out, "Dialogue", [line])


def write_dual_dialog(out, dual) -> None:
def write_dual_dialog(out: TextIO, dual: DualDialog) -> None:
out.write(" <Paragraph>\n <DualDialogue>\n")
write_dialog(out, dual.left)
write_dialog(out, dual.right)
out.write(" </DualDialogue>\n </Paragraph>\n")


def to_fdx(screenplay, out) -> None:
def to_fdx(screenplay: Screenplay, out: TextIO) -> None:
out.write(
'<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n'
'<FinalDraft DocumentType="Script" Template="No" Version="1">\n'
Expand Down
66 changes: 40 additions & 26 deletions screenplain/export/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,20 @@
# Licensed under the MIT license:
# http://www.opensource.org/licenses/mit-license.php

from __future__ import annotations

import os
import os.path
from collections.abc import Callable
from typing import TextIO

from screenplain.richstring import plain
from screenplain.richstring import RichString, plain
from screenplain.types import (
Action,
Dialog,
DualDialog,
PageBreak,
Screenplay,
Section,
Slug,
Transition,
Expand Down Expand Up @@ -43,24 +48,29 @@ class tag:

"""

def __init__(self, out, tag, classes=None):
def __init__(
self, out: TextIO, tag: str, classes: list[str] | set[str] | None = None
) -> None:
self.out = out
self.tag = tag
self.classes = classes

def __enter__(self):
def __enter__(self) -> tag:
if self.classes:
self.out.write(f'<{self.tag} class="{" ".join(self.classes)}">')
else:
self.out.write(f"<{self.tag}>")
return self

def __exit__(self, exception_type, value, traceback):
def __exit__(
self, exception_type: object, value: object, traceback: object
) -> bool:
if not exception_type:
self.out.write(f"</{self.tag}>")
return False


def to_html(text):
def to_html(text: RichString) -> str:
html = text.to_html()
if html == "":
return "&nbsp;"
Expand All @@ -71,7 +81,7 @@ def to_html(text):
class Formatter:
"""Class for converting paragraphs into HTML."""

def __init__(self, out):
def __init__(self, out: TextIO) -> None:
"""Initializes the formatter.

`out` is a file-like object to write to.
Expand All @@ -80,7 +90,7 @@ def __init__(self, out):

"""
self.out = out
self._format_functions = {
self._format_functions: dict[type, Callable[..., None]] = {
Slug: self.format_slug,
Action: self.format_action,
Dialog: self.format_dialog,
Expand All @@ -90,7 +100,7 @@ def __init__(self, out):
PageBreak: self.format_page_break,
}

def convert(self, screenplay) -> None:
def convert(self, screenplay: Screenplay) -> None:
"""Converts a number of paragraphs into HTML and writes
it to the output stream.
`screenplay` is a sequence of paragraphs.
Expand All @@ -103,19 +113,19 @@ def convert(self, screenplay) -> None:
format_function(para)
self.out.write("\n")

def format_dialog(self, dialog) -> None:
def format_dialog(self, dialog: Dialog) -> None:
with self._tag("div", classes=["dialog"]):
self._write_dialog_block(dialog)

def format_dual(self, dual) -> None:
def format_dual(self, dual: DualDialog) -> None:
with self._tag("div", classes=["dual"]):
with self._tag("div", classes=["left"]):
self._write_dialog_block(dual.left)
with self._tag("div", classes=["right"]):
self._write_dialog_block(dual.right)
self.out.write("<br />")

def _write_dialog_block(self, dialog):
def _write_dialog_block(self, dialog: Dialog) -> None:
with self._tag("p", classes=["character"]):
self.out.write(to_html(dialog.character))

Expand All @@ -124,28 +134,28 @@ def _write_dialog_block(self, dialog):
with self._tag("p", classes=classes):
self.out.write(to_html(text))

def format_slug(self, slug) -> None:
def format_slug(self, slug: Slug) -> None:
num = slug.scene_number
with self._tag("h6"):
if num:
with self._tag("span", classes=["scnuml"]):
self.out.write(to_html(slug.scene_number))
self.out.write(to_html(num))
self.out.write(to_html(slug.line))
if num:
with self._tag("span", classes=["scnumr"]):
self.out.write(to_html(slug.scene_number))
self.out.write(to_html(num))
if slug.synopsis:
with self._tag("span", classes=["h6-synopsis"]):
self.out.write(to_html(plain(slug.synopsis)))

def format_section(self, section) -> None:
def format_section(self, section: Section) -> None:
with self._tag(f"h{section.level}"):
self.out.write(to_html(section.text))
if section.synopsis:
with self._tag("span", classes=[f"h{section.level}-synopsis"]):
self.out.write(to_html(plain(section.synopsis)))

def format_action(self, para) -> None:
def format_action(self, para: Action) -> None:
classes = ["action"]
if para.centered:
classes.append("centered")
Expand All @@ -156,23 +166,27 @@ def format_action(self, para) -> None:
self.out.write("<br/>")
self.out.write(to_html(line))

def format_transition(self, para) -> None:
def format_transition(self, para: Transition) -> None:
with self._tag("div", classes=["transition"]):
self.out.write(to_html(para.line))

def format_page_break(self, para) -> None:
def format_page_break(self, para: PageBreak) -> None:
self.page_break_before_next = True

def _tag(self, tag_name, classes=None):
if classes is None:
classes = []
def _tag(self, tag_name: str, classes: list[str] | None = None) -> tag:
tag_classes: list[str] | set[str] = classes if classes is not None else []
if self.page_break_before_next:
self.page_break_before_next = False
classes = set(classes).union(("page-break",))
return tag(self.out, tag_name, classes)
tag_classes = set(tag_classes).union(("page-break",))
return tag(self.out, tag_name, tag_classes)


def convert(screenplay, out, css_file=None, bare=False) -> None:
def convert(
screenplay: Screenplay,
out: TextIO,
css_file: str | None = None,
bare: bool = False,
) -> None:
"""Convert the screenplay into HTML, written to the file-like object `out`.

The output will be a complete HTML document unless `bare` is true.
Expand All @@ -188,7 +202,7 @@ def convert(screenplay, out, css_file=None, bare=False) -> None:
)


def convert_full(screenplay, out, css_file) -> None:
def convert_full(screenplay: Screenplay, out: TextIO, css_file: str) -> None:
"""Convert the screenplay into a complete HTML document,
written to the file-like object `out`.

Expand All @@ -204,7 +218,7 @@ def convert_full(screenplay, out, css_file) -> None:
out.write("</div></body></html>\n")


def convert_bare(screenplay, out) -> None:
def convert_bare(screenplay: Screenplay, out: TextIO) -> None:
"""Convert the screenplay into HTML, written to the file-like object `out`.
Does not create a complete HTML document, as it doesn't include
<html>, <body>, etc.
Expand Down
Loading
Loading