jumpcut 1.0.0

JumpCut is a library and CLI for converting Fountain-formatted text files into FDX, HTML, JSON, text, and PDF formats.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
use crate::pagination::wrapping::ElementType;
use serde::Deserialize;
use std::collections::BTreeMap;

const DUAL_DIALOGUE_CHARACTER_CENTER_LEFT: f32 = 2.9375;
const DUAL_DIALOGUE_CHARACTER_RIGHT_OFFSET: f32 = 3.125;
const DUAL_DIALOGUE_CHARACTER_MAX_WIDTH_CHARS: usize = 29;
const DUAL_DIALOGUE_CHARACTER_CELL_WIDTH_INCHES: f32 = 7.0 / 72.0;

#[derive(Clone, Debug, Deserialize, PartialEq)]
pub struct FdxExtractedSettings {
    pub paragraph_styles: BTreeMap<String, FdxParagraphStyle>,
}

#[derive(Clone, Debug, Deserialize, PartialEq)]
pub struct FdxParagraphStyle {
    #[serde(default)]
    pub first_indent: f32,
    pub left_indent: f32,
    pub right_indent: f32,
    pub space_before: f32,
    pub spacing: f32,
    #[serde(default = "default_alignment")]
    pub alignment: Alignment,
}

#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub enum Alignment {
    Left,
    Center,
    Right,
}

#[derive(Clone, Debug, PartialEq)]
pub struct LayoutGeometry {
    pub action_first_indent: f32,
    pub cold_opening_first_indent: f32,
    pub new_act_first_indent: f32,
    pub end_of_act_first_indent: f32,
    pub dual_dialogue_left_first_indent: f32,
    pub dual_dialogue_right_first_indent: f32,
    pub dual_dialogue_left_character_first_indent: f32,
    pub dual_dialogue_left_parenthetical_first_indent: f32,
    pub dual_dialogue_right_character_first_indent: f32,
    pub dual_dialogue_right_parenthetical_first_indent: f32,
    pub character_first_indent: f32,
    pub dialogue_first_indent: f32,
    pub parenthetical_first_indent: f32,
    pub transition_first_indent: f32,
    pub lyric_first_indent: f32,
    pub action_left: f32,
    pub action_right: f32,
    pub cold_opening_left: f32,
    pub cold_opening_right: f32,
    pub new_act_left: f32,
    pub new_act_right: f32,
    pub end_of_act_left: f32,
    pub end_of_act_right: f32,
    pub dual_dialogue_left_left: f32,
    pub dual_dialogue_left_right: f32,
    pub dual_dialogue_right_left: f32,
    pub dual_dialogue_right_right: f32,
    pub dual_dialogue_left_character_left: f32,
    pub dual_dialogue_left_character_right: f32,
    pub dual_dialogue_left_parenthetical_left: f32,
    pub dual_dialogue_left_parenthetical_right: f32,
    pub dual_dialogue_right_character_left: f32,
    pub dual_dialogue_right_character_right: f32,
    pub dual_dialogue_right_parenthetical_left: f32,
    pub dual_dialogue_right_parenthetical_right: f32,
    pub character_left: f32,
    pub character_right: f32,
    pub dialogue_left: f32,
    pub dialogue_right: f32,
    pub parenthetical_left: f32,
    pub parenthetical_right: f32,
    pub transition_left: f32,
    pub transition_right: f32,
    pub lyric_left: f32,
    pub lyric_right: f32,
    pub cpi: f32,

    pub scene_number_left: f32,
    pub scene_number_right: f32,

    pub action_alignment: Alignment,
    pub cold_opening_alignment: Alignment,
    pub new_act_alignment: Alignment,
    pub end_of_act_alignment: Alignment,
    pub scene_heading_alignment: Alignment,
    pub character_alignment: Alignment,
    pub dialogue_alignment: Alignment,
    pub parenthetical_alignment: Alignment,
    pub transition_alignment: Alignment,
    pub lyric_alignment: Alignment,

    // Vertical Spacing (Blank lines before element)
    pub action_spacing_before: f32,
    pub cold_opening_spacing_before: f32,
    pub new_act_spacing_before: f32,
    pub end_of_act_spacing_before: f32,
    pub scene_heading_spacing_before: f32,
    pub character_spacing_before: f32,
    pub transition_spacing_before: f32,
    pub lyric_spacing_before: f32,

    // Orphan/Widow Limits
    pub orphan_limit: usize,
    pub widow_limit: usize,

    // Spacing
    pub action_line_height: f32,
    pub cold_opening_line_height: f32,
    pub new_act_line_height: f32,
    pub end_of_act_line_height: f32,
    pub scene_heading_line_height: f32,
    pub character_line_height: f32,
    pub dialogue_line_height: f32,
    pub parenthetical_line_height: f32,
    pub transition_line_height: f32,
    pub lyric_line_height: f32,
    pub line_height: f32,

    pub page_width: f32,
    pub page_height: f32,
    pub top_margin: f32,
    pub bottom_margin: f32,
    pub header_margin: f32,
    pub footer_margin: f32,
    pub lines_per_page: f32,
}

impl Default for LayoutGeometry {
    fn default() -> Self {
        Self {
            action_first_indent: 0.0,
            cold_opening_first_indent: 0.0,
            new_act_first_indent: 0.0,
            end_of_act_first_indent: 0.0,
            dual_dialogue_left_first_indent: 0.0,
            dual_dialogue_right_first_indent: 0.0,
            dual_dialogue_left_character_first_indent: 0.0,
            dual_dialogue_left_parenthetical_first_indent: -0.1,
            dual_dialogue_right_character_first_indent: 0.0,
            dual_dialogue_right_parenthetical_first_indent: -0.1,
            character_first_indent: 0.0,
            dialogue_first_indent: 0.0,
            parenthetical_first_indent: -0.1,
            transition_first_indent: 0.0,
            lyric_first_indent: 0.0,
            action_left: 1.5,
            action_right: 7.5,
            cold_opening_left: 1.5,
            cold_opening_right: 7.5,
            new_act_left: 1.5,
            new_act_right: 7.5,
            end_of_act_left: 1.5,
            end_of_act_right: 7.5,
            dual_dialogue_left_left: 1.5,
            dual_dialogue_left_right: 4.375,
            dual_dialogue_right_left: 4.625,
            dual_dialogue_right_right: 7.5,
            dual_dialogue_left_character_left: 2.5,
            dual_dialogue_left_character_right: 4.875,
            dual_dialogue_left_parenthetical_left: 1.75,
            dual_dialogue_left_parenthetical_right: 4.125,
            dual_dialogue_right_character_left: 5.875,
            dual_dialogue_right_character_right: 7.5,
            dual_dialogue_right_parenthetical_left: 4.875,
            dual_dialogue_right_parenthetical_right: 7.25,
            character_left: 3.5,
            character_right: 7.25,
            dialogue_left: 2.5,
            dialogue_right: 6.0,
            parenthetical_left: 3.0,
            parenthetical_right: 5.5,
            transition_left: 5.5,
            transition_right: 7.1,
            lyric_left: 2.5,
            lyric_right: 7.375,
            cpi: 10.0,

            scene_number_left: 0.75,
            scene_number_right: 7.375,

            action_alignment: Alignment::Left,
            cold_opening_alignment: Alignment::Center,
            new_act_alignment: Alignment::Center,
            end_of_act_alignment: Alignment::Center,
            scene_heading_alignment: Alignment::Left,
            character_alignment: Alignment::Left,
            dialogue_alignment: Alignment::Left,
            parenthetical_alignment: Alignment::Left,
            transition_alignment: Alignment::Right,
            lyric_alignment: Alignment::Left,

            action_spacing_before: 1.0,
            cold_opening_spacing_before: 1.0,
            new_act_spacing_before: 0.0,
            end_of_act_spacing_before: 2.0,
            scene_heading_spacing_before: 2.0,
            character_spacing_before: 1.0,
            transition_spacing_before: 1.0,
            lyric_spacing_before: 0.0,

            orphan_limit: 2,
            widow_limit: 2,
            action_line_height: 1.0,
            cold_opening_line_height: 1.0,
            new_act_line_height: 1.0,
            end_of_act_line_height: 1.0,
            scene_heading_line_height: 1.0,
            character_line_height: 1.0,
            dialogue_line_height: 1.0,
            parenthetical_line_height: 1.0,
            transition_line_height: 1.0,
            lyric_line_height: 1.0,
            line_height: 1.0,
            page_width: 8.5,
            page_height: 11.0,
            top_margin: 1.0,
            bottom_margin: 1.0,
            header_margin: 0.5,
            footer_margin: 0.5,
            lines_per_page: 54.0,
        }
    }
}

impl LayoutGeometry {
    pub fn from_fdx_settings(settings: &FdxExtractedSettings) -> Self {
        let mut geometry = Self::default();

        let lpi = 6.0; // Default Final Draft lines per inch

        if let Some(style) = settings.paragraph_styles.get("Action") {
            geometry.action_first_indent = style.first_indent;
            geometry.action_left = style.left_indent;
            geometry.action_right = style.right_indent;
            geometry.action_spacing_before = spacing_lines_from_points(style.space_before, lpi);
            geometry.action_alignment = style.alignment;
            geometry.action_line_height = style.spacing;
        }
        if let Some(style) = settings.paragraph_styles.get("Cold Opening") {
            geometry.cold_opening_first_indent = style.first_indent;
            geometry.cold_opening_left = style.left_indent;
            geometry.cold_opening_right = style.right_indent;
            geometry.cold_opening_spacing_before =
                spacing_lines_from_points(style.space_before, lpi);
            geometry.cold_opening_alignment = style.alignment;
            geometry.cold_opening_line_height = style.spacing;
        }
        if let Some(style) = settings.paragraph_styles.get("New Act") {
            geometry.new_act_first_indent = style.first_indent;
            geometry.new_act_left = style.left_indent;
            geometry.new_act_right = style.right_indent;
            geometry.new_act_spacing_before = spacing_lines_from_points(style.space_before, lpi);
            geometry.new_act_alignment = style.alignment;
            geometry.new_act_line_height = style.spacing;
        }
        if let Some(style) = settings.paragraph_styles.get("End of Act") {
            geometry.end_of_act_first_indent = style.first_indent;
            geometry.end_of_act_left = style.left_indent;
            geometry.end_of_act_right = style.right_indent;
            geometry.end_of_act_spacing_before = spacing_lines_from_points(style.space_before, lpi);
            geometry.end_of_act_alignment = style.alignment;
            geometry.end_of_act_line_height = style.spacing;
        }
        if let Some(style) = settings.paragraph_styles.get("Scene Heading") {
            geometry.scene_heading_spacing_before =
                spacing_lines_from_points(style.space_before, lpi);
            geometry.scene_heading_alignment = style.alignment;
            geometry.scene_heading_line_height = style.spacing;
        }
        if let Some(style) = settings.paragraph_styles.get("Dialogue") {
            geometry.dialogue_first_indent = style.first_indent;
            geometry.dialogue_left = style.left_indent;
            geometry.dialogue_right = style.right_indent;
            geometry.dialogue_alignment = style.alignment;
            geometry.dialogue_line_height = style.spacing;
        }
        if let Some(style) = settings.paragraph_styles.get("Character") {
            geometry.character_first_indent = style.first_indent;
            geometry.character_left = style.left_indent;
            geometry.character_right = style.right_indent;
            geometry.character_spacing_before = spacing_lines_from_points(style.space_before, lpi);
            geometry.character_alignment = style.alignment;
            geometry.character_line_height = style.spacing;
        }
        if let Some(style) = settings.paragraph_styles.get("Parenthetical") {
            geometry.parenthetical_first_indent = style.first_indent;
            geometry.parenthetical_left = style.left_indent;
            geometry.parenthetical_right = style.right_indent;
            geometry.parenthetical_alignment = style.alignment;
            geometry.parenthetical_line_height = style.spacing;
        }
        if let Some(style) = settings.paragraph_styles.get("Lyric") {
            geometry.lyric_first_indent = style.first_indent;
            geometry.lyric_left = style.left_indent;
            geometry.lyric_right = style.right_indent;
            geometry.lyric_spacing_before = spacing_lines_from_points(style.space_before, lpi);
            geometry.lyric_alignment = style.alignment;
            geometry.lyric_line_height = style.spacing;
        }
        if let Some(style) = settings.paragraph_styles.get("Transition") {
            geometry.transition_first_indent = style.first_indent;
            geometry.transition_left = style.left_indent;
            geometry.transition_right = style.right_indent;
            geometry.transition_spacing_before = spacing_lines_from_points(style.space_before, lpi);
            geometry.transition_alignment = style.alignment;
            geometry.transition_line_height = style.spacing;
        }

        geometry.line_height = geometry.dialogue_line_height;
        geometry
    }

    pub fn calculate_usable_height_points(&self) -> f32 {
        (self.page_height - self.top_margin - self.bottom_margin) * 72.0
    }

    pub fn calculate_line_step(&self) -> f32 {
        self.calculate_usable_height_points() / self.lines_per_page
    }
}

fn spacing_lines_from_points(space_before_points: f32, lines_per_inch: f32) -> f32 {
    let points_per_line = 72.0 / lines_per_inch;
    space_before_points / points_per_line
}

/// Calculates the exact character capacity for an element given its physical
/// margin bounds (in inches) and the characters-per-inch (CPI) of the typeface.
pub fn calculate_element_width(geometry: &LayoutGeometry, element_type: ElementType) -> usize {
    if matches!(
        element_type,
        ElementType::DualDialogueCharacterLeft | ElementType::DualDialogueCharacterRight
    ) {
        return DUAL_DIALOGUE_CHARACTER_MAX_WIDTH_CHARS;
    }

    let (left_indent, right_indent) = match element_type {
        ElementType::Action => (geometry.action_left, geometry.action_right),
        ElementType::ColdOpening => (geometry.cold_opening_left, geometry.cold_opening_right),
        ElementType::NewAct => (geometry.new_act_left, geometry.new_act_right),
        ElementType::EndOfAct => (geometry.end_of_act_left, geometry.end_of_act_right),
        ElementType::SceneHeading => (geometry.action_left, geometry.action_right), // Standard default
        ElementType::DualDialogueLeft => (
            geometry.dual_dialogue_left_left,
            geometry.dual_dialogue_left_right,
        ),
        ElementType::DualDialogueRight => (
            geometry.dual_dialogue_right_left,
            geometry.dual_dialogue_right_right,
        ),
        ElementType::DualDialogueCharacterLeft => (
            geometry.dual_dialogue_left_character_left,
            geometry.dual_dialogue_left_character_right,
        ),
        ElementType::DualDialogueCharacterRight => (
            geometry.dual_dialogue_right_character_left,
            geometry.dual_dialogue_right_character_right,
        ),
        ElementType::DualDialogueParentheticalLeft => (
            geometry.dual_dialogue_left_parenthetical_left,
            geometry.dual_dialogue_left_parenthetical_right,
        ),
        ElementType::DualDialogueParentheticalRight => (
            geometry.dual_dialogue_right_parenthetical_left,
            geometry.dual_dialogue_right_parenthetical_right,
        ),
        ElementType::Character => (geometry.character_left, geometry.character_right),
        ElementType::Dialogue => (geometry.dialogue_left, geometry.dialogue_right),
        ElementType::Parenthetical => (geometry.parenthetical_left, geometry.parenthetical_right),
        ElementType::Transition => (geometry.transition_left, geometry.transition_right),
        ElementType::Lyric => (geometry.lyric_left, geometry.lyric_right),
    };

    let width_inches = right_indent - left_indent;
    let mut chars = (width_inches * geometry.cpi).round() as usize;

    // Apply the Final Draft specific quirk for the confirmed full-width family
    // and parentheticals, which render one character wider than normal rounding.
    if matches!(
        element_type,
        ElementType::Action
            | ElementType::SceneHeading
            | ElementType::NewAct
            | ElementType::EndOfAct
            | ElementType::Parenthetical
            | ElementType::DualDialogueParentheticalLeft
            | ElementType::DualDialogueParentheticalRight
    ) {
        chars += 1;
    }

    chars
}

fn dual_dialogue_character_anchor_text(text: &str) -> &str {
    text.split(" (").next().unwrap_or(text)
}

pub fn dual_dialogue_character_left_indent(text: &str, side: u8) -> f32 {
    let visible_len = dual_dialogue_character_anchor_text(text)
        .chars()
        .count()
        .clamp(1, DUAL_DIALOGUE_CHARACTER_MAX_WIDTH_CHARS);
    let cue_width = visible_len as f32 * DUAL_DIALOGUE_CHARACTER_CELL_WIDTH_INCHES;
    let left = DUAL_DIALOGUE_CHARACTER_CENTER_LEFT - (cue_width / 2.0);

    match side {
        1 => left,
        _ => left + DUAL_DIALOGUE_CHARACTER_RIGHT_OFFSET,
    }
}

pub fn line_height_for_element_type(geometry: &LayoutGeometry, element_type: ElementType) -> f32 {
    match element_type {
        ElementType::Action => geometry.action_line_height,
        ElementType::ColdOpening => geometry.cold_opening_line_height,
        ElementType::NewAct => geometry.new_act_line_height,
        ElementType::EndOfAct => geometry.end_of_act_line_height,
        ElementType::SceneHeading => geometry.scene_heading_line_height,
        ElementType::Character => geometry.character_line_height,
        ElementType::Dialogue => geometry.dialogue_line_height,
        ElementType::Parenthetical => geometry.parenthetical_line_height,
        ElementType::Transition => geometry.transition_line_height,
        ElementType::Lyric => geometry.lyric_line_height,
        ElementType::DualDialogueLeft | ElementType::DualDialogueRight => {
            geometry.dialogue_line_height
        }
        ElementType::DualDialogueCharacterLeft | ElementType::DualDialogueCharacterRight => {
            geometry.character_line_height
        }
        ElementType::DualDialogueParentheticalLeft
        | ElementType::DualDialogueParentheticalRight => geometry.parenthetical_line_height,
    }
}

fn default_alignment() -> Alignment {
    Alignment::Left
}