You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.ts → exportPdf()
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.ts → GeneratePdf()
if(outputpath=="$STATS$")returnpdfmaker.get_pdf_stats(pdf_options);elseif(outputpath=="$PREVIEW$")returnpdfmaker.get_pdf_base64(pdf_options)elsepdfmaker.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.ts → get_pdf()
exportvarget_pdf=asyncfunction(opts,progress){progress.report({message: "Processing document",increment: 25});vardoc=awaitinitDoc(opts);generate(doc,opts,);// ← async function, NOT awaitedprogress.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:
Run the Fountain: Export PDF debug command (fountain.exportpdfdebug, the openFileOnSave=true variant).
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)}elseif(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."
This pairs a dual === "left" character with the firstdual === "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.ts → count_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.ts → generate(), the dual render branch
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
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.
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
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)
Fix the async chain. In pdf.ts, return pdfmaker.get_pdf(pdf_options, progress) from GeneratePdf (and make GeneratePdfasync). 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.
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.
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.
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.
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.
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, andsrc/pdf/liner.tsthat 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 taggedpdfkit bugfor 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
master)^) blocks.Defect 1 — The entire export is a fire-and-forget async chain (no
await, noreturn)The call chain from the command down to the file write is broken at every link.
src/commands.ts→exportPdf()GeneratePdfreturnsundefined(see below), so thewithProgresstask callback resolves essentially instantly. The progress notification vanishes while the real work is still happening on a detached promise.src/pdf/pdf.ts→GeneratePdf()The stats and preview branches
returntheir promise; the actual file-export branch does not. SoGeneratePdfreturnsundefinedand nobody can ever know when the export finished or whether it failed.src/pdf/pdfmaker.ts→get_pdf()generateis declaredasync function generate(...), yet it is invoked withoutawait. Compare with the correct usages in the same file:get_pdf_statsandget_pdf_base64both doawait generate(doc, opts, ...). So the file-export path is the odd one out. It currently "works by accident" only becausegenerate()happens to contain no internalawaittoday, so it runs to completion synchronously before returning a resolved promise — masking a latent race that will silently break the moment anyone adds anawaitinsidegenerate()(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 —
openFileOnSaveopens the PDF before it has been writtenDirectly caused by Defect 1. The
fountain.exportpdfdebugcommand is registered as:Reproduction
.fountainfile.fountain.exportpdfdebug, theopenFileOnSave=truevariant).screenplay.pdf, which either does not exist yet or is still being streamed to disk.Why
Because
GeneratePdf(...)inside thewithProgresscallback is not awaited (Defect 1), the callback resolves immediately and execution falls through to:…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/catchanywhere inexportPdf,GeneratePdf, orget_pdf. The only error surface in the entire pipeline is the filesystem write handler insidecreate_simplestream():That handler only catches stream write errors (
ENOENT/EPERMon the output path). It catches nothing that happens during generation. So ifinitDoc()throws — for examplefontFinder.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: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.ts→fold_dual_dialogue()/get_first_unfolded_dual_right_index_from()This pairs a
dual === "left"character with the firstdual === "right"character found scanning forward from it. When a script contains multiple dual-dialogue blocks (extremely common), this greedy forward scan happily grabs arightblock 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.ts→count_dialogue_tokens()+ the padding logic infold_dual_dialogue()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_tokenscounts tokens, but tokens have already been split into multiple wrapped lines bysplit_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.ts→generate(), the dual render branchThe right column advances its own
y_rightindependently, while the left column'syis only incremented later by the genericy++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.ts→initDoc()Two problems:
__dirnameup one directory and assuming acourierprime/sibling exists. This is brittle under VS Code's extension bundling (esbuild/webpack can change__dirnameresolution), and the path is never checked for existence before being handed toregisterFont. If the TTFs aren't there, PDFKit throws at the firstdoc.font('ScriptNormal')— and per Defect 3, that throw is swallowed silently.fontFinder.listVariants(opts.font)isawaited with no error handling. If the user-suppliedFont:title-page key names a font thatfont-findercan'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 bugand 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 pathsrc/pdf/pdfmaker.ts→create_simplestream()new Buffer()has been deprecated since Node 6 and emits runtime deprecation warnings; in stricter environments it can throw. This should beBuffer.from(...). The entire base64-encode-then-decode round-trip is also pointless overhead — PDFKit already emitsBufferchunks that could be written directly to the stream.Impact
For a screenwriting tool whose headline feature is "industry-standard PDF generation":
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)
pdf.ts,return pdfmaker.get_pdf(pdf_options, progress)fromGeneratePdf(and makeGeneratePdfasync). Inpdfmaker.ts,await generate(doc, opts)insideget_pdf. Incommands.ts,await GeneratePdf(...)inside thewithProgresscallback so the progress notification actually reflects completion, and move theif (openFileOnSave) openFile(...)inside the callback after the await. This alone fixes Defects 1 and 2.get_pdf(and thewithProgresscallback) intry/catchand surface every failure withvscode.window.showErrorMessage, including a non-stream generation failure. Fixes Defect 3.fold_dual_dialoguewith proper left/right block matching (the right block must be the one immediately following its left partner, not the firstdual==="right"anywhere after). Compute column padding from rendered line counts, not token counts, and pad symmetrically in both directions. Reconciley/y_rightingenerate()so the taller column governs the advance. Fixes Defect 4 and the right-column-loss in Add full support for UTF-8 characters #112.fs.existsSyncthe Courier Prime path beforeregisterFont; wrapfontFinder.listVariantsin try/catch with a clear error and a fallback to Courier Prime. Fixes Defect 5.pdfkit bugand 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.new Buffer(...)withBuffer.from(...)and drop the base64 round-trip. Fixes Defect 7.Related issues (all still open)
bug,pdfkit bug, since 2021) (Defects 4 & 6)bug,pdfkit bug, since 2020) (Defect 6)bug,pdfkit bug) (Defects 5 & 6)bug,pdfkit bug) (Defect 6)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.