Skip to content

PDF export is fundamentally broken: broken async chain, silent failures, dual-dialogue corruption, and no non-Latin/RTL support #254

Description

@DijieDeng

Summary

The PDF export feature — arguably the core deliverable of a screenwriting tool ("industry-standard PDF generation" is literally the second bullet in the README) — is broken in multiple, independent, code-level ways. This is not one flaky edge case; it is a pile of defects across src/commands.ts, src/pdf/pdf.ts, src/pdf/pdfmaker.ts, and src/pdf/liner.ts that combine to make the feature unreliable for Latin scripts and effectively unusable for anyone writing in CJK, Cyrillic-extended, Arabic, or Hebrew. Several of these have been open and tagged pdfkit bug for 4–5 years (#112 from 2021, #98 from 2020, #146 from 2021, #133 from 2021, #209 from 2023) with no resolution, which suggests the export path is being treated as "someone else's problem" rather than something that can actually be fixed in this codebase.

I dug through the actual export code before filing this so it isn't a vague complaint. Specifics below.


Environment

  • Extension: BetterFountain (latest master)
  • VS Code: any recent version
  • Reproduced with plain ASCII scripts, CJK scripts, Hebrew scripts, and Fountain dual-dialogue (^) blocks.

Defect 1 — The entire export is a fire-and-forget async chain (no await, no return)

The call chain from the command down to the file write is broken at every link.

src/commands.tsexportPdf()

vscode.window.withProgress({ title: "Exporting PDF...", location: vscode.ProgressLocation.Notification }, async (progress) => {
    GeneratePdf(filepath.fsPath, config, exportconfig, parsed, progress);   // ← not awaited, not returned
});
if (openFileOnSave) { openFile(filepath.fsPath); }                          // ← runs BEFORE the file exists (see Defect 2)

GeneratePdf returns undefined (see below), so the withProgress task callback resolves essentially instantly. The progress notification vanishes while the real work is still happening on a detached promise.

src/pdf/pdf.tsGeneratePdf()

if (outputpath == "$STATS$")
    return pdfmaker.get_pdf_stats(pdf_options);
else if (outputpath == "$PREVIEW$")
    return pdfmaker.get_pdf_base64(pdf_options)
else
    pdfmaker.get_pdf(pdf_options, progress);   // ← async, but neither awaited nor returned

The stats and preview branches return their promise; the actual file-export branch does not. So GeneratePdf returns undefined and nobody can ever know when the export finished or whether it failed.

src/pdf/pdfmaker.tsget_pdf()

export var get_pdf = async function (opts, progress) {
    progress.report({ message: "Processing document", increment: 25 });
    var doc = await initDoc(opts);
    generate(doc, opts,);                                       // ← async function, NOT awaited
    progress.report({ message: "Writing to disk", increment: 25 });
    finishDoc(doc, opts.filepath);
};

generate is declared async function generate(...), yet it is invoked without await. Compare with the correct usages in the same file: get_pdf_stats and get_pdf_base64 both do await generate(doc, opts, ...). So the file-export path is the odd one out. It currently "works by accident" only because generate() happens to contain no internal await today, so it runs to completion synchronously before returning a resolved promise — masking a latent race that will silently break the moment anyone adds an await inside generate() (e.g. for the image/font loading it clearly wants to do).

Net effect: the export is completely untracked. There is no way for the caller to await completion, react to failure, or chain the "open file" step correctly.


Defect 2 — openFileOnSave opens the PDF before it has been written

Directly caused by Defect 1. The fountain.exportpdfdebug command is registered as:

// src/extension.ts
vscode.commands.registerCommand('fountain.exportpdfdebug', async () => commands.exportPdf(false, true));
//                                                                              ^ showSaveDialog=false, openFileOnSave=true

Reproduction

  1. Open any .fountain file.
  2. Run the Fountain: Export PDF debug command (fountain.exportpdfdebug, the openFileOnSave=true variant).
  3. Observe: VS Code immediately attempts to open screenplay.pdf, which either does not exist yet or is still being streamed to disk.

Why

Because GeneratePdf(...) inside the withProgress callback is not awaited (Defect 1), the callback resolves immediately and execution falls through to:

if (openFileOnSave) { openFile(filepath.fsPath); }

…before finishDoc()/create_simplestream() has written a single byte. On slower disks / larger scripts this reliably opens a missing or truncated file. On fast machines it's a race that occasionally appears to work, which is worse than consistently failing because it hides the bug.


Defect 3 — Silent failure: zero error handling in the export pipeline

There is no try/catch anywhere in exportPdf, GeneratePdf, or get_pdf. The only error surface in the entire pipeline is the filesystem write handler inside create_simplestream():

// src/pdf/pdfmaker.ts → create_simplestream()
stream.on('error', function (err) {
    if (err.code == "ENOENT") { vscode.window.showErrorMessage("Unable to export PDF! The specified location does not exist: " + err.path) }
    else if (err.code == "EPERM") { vscode.window.showErrorMessage("Unable to export PDF! You do not have the permission...") }
    else { vscode.window.showErrorMessage(err.message); }
});

That handler only catches stream write errors (ENOENT/EPERM on the output path). It catches nothing that happens during generation. So if initDoc() throws — for example fontFinder.listVariants(opts.font) rejecting, or the hardcoded Courier Prime font path being wrong (Defect 5), or PDFKit choking on a glyph — the rejection propagates into a detached, unhandled promise. Result:

  • No PDF is produced.
  • The progress notification just disappears.
  • The user gets no error message at all.

This is the worst possible failure mode for a "save my screenplay" feature: silent data loss of the export, with no indication anything went wrong. Every generation error needs to be caught and surfaced via vscode.window.showErrorMessage.


Defect 4 — Dual / simultaneous dialogue is corrupted in the PDF

This is issue #247 (open, zero comments, no labels) and the right-column-loss half of issue #112. The live preview renders simultaneous dialogue correctly; the PDF does not.

Reproduction

Use the exact script from #247 — multiple ^ dual-dialogue blocks in sequence. In the exported PDF, two separate simultaneous-dialogue blocks get "mashed into one line" and the second block "only renders the first dialogue."

Where the code falls apart

src/pdf/liner.tsfold_dual_dialogue() / get_first_unfolded_dual_right_index_from()

var get_first_unfolded_dual_right_index_from = (index) => {
    for (var i = index; i < lines.length; i++) {
        if (lines[i].token && lines[i].token.type === "character" && lines[i].token.dual === "right") {
            return i;
        }
    }
    return -1;
};

This pairs a dual === "left" character with the first dual === "right" character found scanning forward from it. When a script contains multiple dual-dialogue blocks (extremely common), this greedy forward scan happily grabs a right block that belongs to a different speaker pair, folding the wrong columns together. That is precisely the "mashes two lots of simultaneous dialogue into one line" symptom in #247.

src/pdf/liner.tscount_dialogue_tokens() + the padding logic in fold_dual_dialogue()

if (dialogue_tokens > left_tokens) {
    // pad the LEFT column with dummy lines so it matches the RIGHT length
    ...
}

Padding is only inserted when the right side is longer. There is no symmetric padding when the left side is longer, so the two columns end up with mismatched heights. Worse, count_dialogue_tokens counts tokens, but tokens have already been split into multiple wrapped lines by split_token/split_text. Equal token counts do not mean equal rendered line counts, so the columns desync vertically even when the pairing is nominally correct.

src/pdf/pdfmaker.tsgenerate(), the dual render branch

if (line.token && line.token.dual) {
    if (line.right_column) {
        var y_right = y;
        line.right_column.forEach(function (right_line) {
            ...
            doc.text2(right_line.text, feed_right, print.top_margin + print.font_height * y_right++, right_text_properties);
        });
    }
    feed -= (feed - print.left_margin) / 2;
}

The right column advances its own y_right independently, while the left column's y is only incremented later by the generic y++ at the bottom of the branch. There is no reconciliation of the two columns' final heights, so whichever side is taller overflows or collides with the following content. Combined with the non-Latin glyph problem (Defect 6), this is exactly why #112 shows the entire right column of Japanese dual dialogue simply missing from the exported PDF while the HTML preview shows it fine.


Defect 5 — Fragile, unvalidated font loading that can silently kill the export

src/pdf/pdfmaker.tsinitDoc()

var fp = __dirname.slice(0, __dirname.lastIndexOf(path.sep)) + path.sep + 'courierprime' + path.sep
doc.registerFont('ScriptNormal', fp + 'courier-prime.ttf');
doc.registerFont('ScriptBold', fp + 'courier-prime-bold.ttf');
doc.registerFont('ScriptBoldOblique', fp + 'courier-prime-bold-italic.ttf');
doc.registerFont('ScriptOblique', fp + 'courier-prime-italic.ttf');
if (opts.font != "Courier Prime") {
    var variants = await fontFinder.listVariants(opts.font);
    ...
}

Two problems:

  1. The path is computed by string-slicing __dirname up one directory and assuming a courierprime/ sibling exists. This is brittle under VS Code's extension bundling (esbuild/webpack can change __dirname resolution), and the path is never checked for existence before being handed to registerFont. If the TTFs aren't there, PDFKit throws at the first doc.font('ScriptNormal') — and per Defect 3, that throw is swallowed silently.
  2. fontFinder.listVariants(opts.font) is awaited with no error handling. If the user-supplied Font: title-page key names a font that font-finder can't resolve, this rejects → silent failure (Defect 3 again).

Defect 6 — Non-Latin / CJK / RTL rendering is completely broken (the "pdfkit bug" cluster)

This is the cluster that has been rotting since 2020–2021: #112 (Japanese), #98 (Chinese), #146 (Chinese, "even with dedicated font command"), #133 (non-English: shows in live preview, blank in PDF), #209 (Hebrew → "gibberish and spaces all around the page").

Root cause, in code

All PDF text is rendered through doc.text2()addTextbox(textobjects, doc, ...) (textbox-for-pdfkit) on top of PDFKit. PDFKit does not do complex text shaping, does not perform bidirectional reordering, and does not subset/embed glyphs for code points absent from the registered font. Meanwhile the live preview uses the browser's HTML/CSS text shaping, which is why the preview is correct and the PDF is not. So the "it works in preview but not in export" symptom reported across #112/#133/#146 is not a mystery — it's architectural: the preview and the PDF use two completely different text-rendering backends, and only one of them (the HTML one) actually shapes text.

Concretely, in initDoc() only Courier Prime (plus an optional single system font) is ever registered, and no font with CJK/Arabic/Hebrew coverage is embedded. So:

Tagging these all as pdfkit bug and leaving them open for half a decade is not a strategy. If PDFKit genuinely can't shape these scripts (it can't, on its own), then the export path needs a shaping/bidi layer (e.g. a font with the needed coverage + a harfbuzz-shaped path, or rendering via a headless browser/Puppeteer the way the preview already effectively does). The current architecture guarantees these scripts will never export correctly.


Defect 7 — Deprecated new Buffer() in the write path

src/pdf/pdfmaker.tscreate_simplestream()

stream.write(new Buffer(buffer.toString('base64'), 'base64'));

new Buffer() has been deprecated since Node 6 and emits runtime deprecation warnings; in stricter environments it can throw. This should be Buffer.from(...). The entire base64-encode-then-decode round-trip is also pointless overhead — PDFKit already emits Buffer chunks that could be written directly to the stream.


Impact

For a screenwriting tool whose headline feature is "industry-standard PDF generation":

  • Latin scripts: the export mostly works if nothing throws, but the open-on-save race (Defect 2) and total absence of error reporting (Defect 3) mean failures are invisible, and dual dialogue is corrupted (Defect 4).
  • CJK / Cyrillic-extended / Arabic / Hebrew: the export is broken by design and has been for years (Defect 6). For these users the feature is not "buggy," it is non-functional. The README's "industry-standard PDF generation" claim is, for them, simply false.

A screenplay writer's single most important deliverable is a correctly formatted PDF they can send to a producer. Right now this feature cannot reliably produce one.


Suggested fixes (in priority order)

  1. Fix the async chain. In pdf.ts, return pdfmaker.get_pdf(pdf_options, progress) from GeneratePdf (and make GeneratePdf async). In pdfmaker.ts, await generate(doc, opts) inside get_pdf. In commands.ts, await GeneratePdf(...) inside the withProgress callback so the progress notification actually reflects completion, and move the if (openFileOnSave) openFile(...) inside the callback after the await. This alone fixes Defects 1 and 2.
  2. Add error handling. Wrap the body of get_pdf (and the withProgress callback) in try/catch and surface every failure with vscode.window.showErrorMessage, including a non-stream generation failure. Fixes Defect 3.
  3. Fix dual-dialogue pairing. Replace the greedy forward scan in fold_dual_dialogue with proper left/right block matching (the right block must be the one immediately following its left partner, not the first dual==="right" anywhere after). Compute column padding from rendered line counts, not token counts, and pad symmetrically in both directions. Reconcile y/y_right in generate() so the taller column governs the advance. Fixes Defect 4 and the right-column-loss in Add full support for UTF-8 characters #112.
  4. Validate fonts before use. fs.existsSync the Courier Prime path before registerFont; wrap fontFinder.listVariants in try/catch with a clear error and a fallback to Courier Prime. Fixes Defect 5.
  5. Solve non-Latin rendering. Either embed a font with the required coverage and add a shaping/bidi pass before handing text to PDFKit, or render the export through the same HTML pipeline the preview already uses (e.g. headless browser → PDF). Stop filing these under pdfkit bug and hoping. Closes Add full support for UTF-8 characters #112, hey guys,here are some problems in Chinese. #98, Chinese characters cannot rendered in PDF even if dedicated font command #146, Non-English Characters: Shown in live-preview, but do not rendered in PDF #133, PDF exports not working with right to left languages #209.
  6. Replace new Buffer(...) with Buffer.from(...) and drop the base64 round-trip. Fixes Defect 7.

Related issues (all still open)

I'm happy to open a PR for fixes 1, 2, 6, and 7 — those are mechanical and low-risk. Fixes 3–5 need a design decision (especially 5) and I'd want maintainer input before touching the dual-dialogue and font pipelines.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions