@sutrata/editor 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/README.md +87 -0
  2. package/dist/ai/VoiceConfirmDialog.d.ts +1 -0
  3. package/dist/ai/VoiceConfirmDialog.js +6 -6
  4. package/dist/ai/VoiceToolbar.d.ts +1 -0
  5. package/dist/ai/VoiceToolbar.js +22 -16
  6. package/dist/ai/clean-response.d.ts +7 -0
  7. package/dist/ai/clean-response.js +58 -0
  8. package/dist/ai/format-transcript.d.ts +3 -0
  9. package/dist/ai/format-transcript.js +7 -0
  10. package/dist/ai/prompts.d.ts +8 -1
  11. package/dist/ai/prompts.js +34 -7
  12. package/dist/autocomplete/suggestions.js +1 -1
  13. package/dist/context/DocumentContext.d.ts +28 -4
  14. package/dist/context/DocumentContext.js +67 -37
  15. package/dist/editor/EditorView.js +32 -4
  16. package/dist/editor/SelectionMenu.js +5 -3
  17. package/dist/editor/external-decorations-plugin.d.ts +23 -0
  18. package/dist/editor/external-decorations-plugin.js +151 -0
  19. package/dist/editor/omitted-scene-plugin.d.ts +9 -0
  20. package/dist/editor/omitted-scene-plugin.js +44 -0
  21. package/dist/editor/plugins.js +4 -0
  22. package/dist/editor/private-metadata-plugin.d.ts +10 -0
  23. package/dist/editor/private-metadata-plugin.js +60 -0
  24. package/dist/editor/prosemirror-to-sutra.js +179 -74
  25. package/dist/editor/scene-heading-view.js +2 -0
  26. package/dist/editor/schema.js +69 -12
  27. package/dist/editor/sutra-to-prosemirror.d.ts +2 -0
  28. package/dist/editor/sutra-to-prosemirror.js +32 -34
  29. package/dist/extensions/ai-provider.d.ts +44 -0
  30. package/dist/extensions/ai-provider.js +27 -0
  31. package/dist/extensions/collab.d.ts +21 -0
  32. package/dist/extensions/collab.js +6 -0
  33. package/dist/extensions/command-registry.d.ts +49 -0
  34. package/dist/extensions/command-registry.js +28 -0
  35. package/dist/extensions/decorations.d.ts +48 -0
  36. package/dist/extensions/decorations.js +6 -0
  37. package/dist/extensions/export-registry.d.ts +59 -0
  38. package/dist/extensions/export-registry.js +10 -0
  39. package/dist/extensions/panel-registry.d.ts +30 -0
  40. package/dist/extensions/panel-registry.js +16 -0
  41. package/dist/extensions/registries.d.ts +15 -0
  42. package/dist/extensions/registries.js +12 -0
  43. package/dist/extensions/registry-store.d.ts +26 -0
  44. package/dist/extensions/registry-store.js +35 -0
  45. package/dist/extensions/session.d.ts +29 -0
  46. package/dist/extensions/session.js +20 -0
  47. package/dist/extensions/speech-provider.d.ts +35 -0
  48. package/dist/extensions/speech-provider.js +17 -0
  49. package/dist/file/builtin-exporters.d.ts +9 -0
  50. package/dist/file/builtin-exporters.js +72 -0
  51. package/dist/file/docx-exporter.js +7 -4
  52. package/dist/file/docx-importer.d.ts +9 -0
  53. package/dist/file/docx-importer.js +47 -19
  54. package/dist/file/scene-number.d.ts +5 -0
  55. package/dist/file/scene-number.js +8 -0
  56. package/dist/file/workflow-reports.js +13 -7
  57. package/dist/findreplace/FindReplace.js +5 -2
  58. package/dist/import/AIImportDialog.d.ts +11 -0
  59. package/dist/import/AIImportDialog.js +112 -0
  60. package/dist/import/ai-import.d.ts +51 -0
  61. package/dist/import/ai-import.js +200 -0
  62. package/dist/import/apply-import.d.ts +7 -0
  63. package/dist/import/apply-import.js +35 -0
  64. package/dist/import/extract.d.ts +17 -0
  65. package/dist/import/extract.js +18 -0
  66. package/dist/index.d.ts +17 -2
  67. package/dist/index.js +7 -0
  68. package/dist/locales/bn.json +8 -0
  69. package/dist/locales/en.json +8 -0
  70. package/dist/locales/gu.json +8 -0
  71. package/dist/locales/hi.json +8 -0
  72. package/dist/locales/kn.json +8 -0
  73. package/dist/locales/ml.json +8 -0
  74. package/dist/locales/mr.json +8 -0
  75. package/dist/locales/or.json +8 -0
  76. package/dist/locales/pa.json +8 -0
  77. package/dist/locales/si.json +8 -0
  78. package/dist/locales/ta.json +8 -0
  79. package/dist/locales/te.json +8 -0
  80. package/dist/navigator/SceneNavigator.js +125 -131
  81. package/dist/navigator/StatisticsDialog.js +5 -1
  82. package/dist/navigator/scene-edits.d.ts +18 -0
  83. package/dist/navigator/scene-edits.js +143 -0
  84. package/dist/navigator/scene-list.js +4 -4
  85. package/dist/navigator/scene-numbering.d.ts +37 -0
  86. package/dist/navigator/scene-numbering.js +88 -0
  87. package/dist/settings/SettingsDialog.js +8 -120
  88. package/dist/shell/AppBar.js +21 -2
  89. package/dist/shell/AppShell.js +18 -4
  90. package/dist/shell/ElementToolbar.d.ts +5 -0
  91. package/dist/shell/ElementToolbar.js +35 -25
  92. package/dist/shell/LanguageStatus.js +6 -0
  93. package/dist/shell/StatusBar.js +11 -6
  94. package/dist/shell/builtin-commands.d.ts +10 -0
  95. package/dist/shell/builtin-commands.js +43 -0
  96. package/dist/shell/icons.d.ts +6 -0
  97. package/dist/shell/icons.js +6 -0
  98. package/dist/shell/use-commands.d.ts +7 -0
  99. package/dist/shell/use-commands.js +24 -0
  100. package/dist/shell/use-panel-context.d.ts +3 -0
  101. package/dist/shell/use-panel-context.js +39 -0
  102. package/dist/source/SourceView.js +18 -1
  103. package/dist/styles/screenplay.css +113 -1
  104. package/dist/titlepage/ExportDialog.d.ts +5 -0
  105. package/dist/titlepage/ExportDialog.js +59 -96
  106. package/package.json +32 -5
  107. package/dist/ai/OnboardingDialog.d.ts +0 -6
  108. package/dist/ai/OnboardingDialog.js +0 -115
  109. package/dist/ai/ai-client.d.ts +0 -71
  110. package/dist/ai/ai-client.js +0 -494
  111. package/dist/ai/generated-providers.json +0 -3696
  112. package/dist/ai/voice-service.d.ts +0 -15
  113. package/dist/ai/voice-service.js +0 -81
  114. package/dist/editor/character-view.d.ts +0 -35
  115. package/dist/editor/character-view.js +0 -112
  116. package/dist/editor/transliterate-input.d.ts +0 -10
  117. package/dist/editor/transliterate-input.js +0 -88
  118. package/dist/extensions/types.d.ts +0 -73
  119. package/dist/extensions/types.js +0 -12
  120. package/scripts/copy-assets.mjs +0 -16
  121. package/scripts/generate-providers.mjs +0 -127
  122. package/src/ai/OnboardingDialog.tsx +0 -390
  123. package/src/ai/VoiceConfirmDialog.tsx +0 -137
  124. package/src/ai/VoiceToolbar.tsx +0 -321
  125. package/src/ai/ai-client.ts +0 -596
  126. package/src/ai/commands.ts +0 -164
  127. package/src/ai/generated-providers.json +0 -3696
  128. package/src/ai/prompts.ts +0 -62
  129. package/src/ai/voice-service.ts +0 -96
  130. package/src/autocomplete/AutocompletePlugin.ts +0 -21
  131. package/src/autocomplete/suggestions.ts +0 -147
  132. package/src/context/DocumentContext.tsx +0 -648
  133. package/src/desktop-api.d.ts +0 -40
  134. package/src/editor/EditorView.tsx +0 -82
  135. package/src/editor/SelectionMenu.tsx +0 -152
  136. package/src/editor/attr-pencil-plugin.ts +0 -172
  137. package/src/editor/character-decoration-plugin.ts +0 -104
  138. package/src/editor/editor-bus.ts +0 -51
  139. package/src/editor/find-highlight-plugin.ts +0 -68
  140. package/src/editor/frontmatter-field-view.ts +0 -52
  141. package/src/editor/frontmatter-field.ts +0 -115
  142. package/src/editor/grapheme-cursor.ts +0 -99
  143. package/src/editor/ime-plugin.ts +0 -85
  144. package/src/editor/insert-helper.ts +0 -43
  145. package/src/editor/keymap.ts +0 -124
  146. package/src/editor/language-class-plugin.ts +0 -91
  147. package/src/editor/plugins.ts +0 -26
  148. package/src/editor/prosemirror-to-sutra.ts +0 -192
  149. package/src/editor/scene-heading-view.ts +0 -147
  150. package/src/editor/schema.ts +0 -156
  151. package/src/editor/sutra-to-prosemirror.ts +0 -156
  152. package/src/extensions/storage-adapter.ts +0 -65
  153. package/src/extensions/types.ts +0 -60
  154. package/src/file/docx-exporter.ts +0 -585
  155. package/src/file/docx-importer.ts +0 -313
  156. package/src/file/print-paginator.ts +0 -301
  157. package/src/file/templates.ts +0 -73
  158. package/src/file/workflow-reports.ts +0 -820
  159. package/src/findreplace/FindReplace.tsx +0 -242
  160. package/src/findreplace/find-engine.ts +0 -97
  161. package/src/findreplace/scroll-to-match.ts +0 -89
  162. package/src/fonts/tofu-detect.ts +0 -53
  163. package/src/i18n/LanguageContext.tsx +0 -207
  164. package/src/i18n/language-detect.ts +0 -73
  165. package/src/i18n/useTranslation.tsx +0 -74
  166. package/src/index.ts +0 -27
  167. package/src/locales/bn.json +0 -143
  168. package/src/locales/en.json +0 -143
  169. package/src/locales/gu.json +0 -143
  170. package/src/locales/hi.json +0 -143
  171. package/src/locales/kn.json +0 -143
  172. package/src/locales/ml.json +0 -143
  173. package/src/locales/mr.json +0 -143
  174. package/src/locales/or.json +0 -143
  175. package/src/locales/pa.json +0 -143
  176. package/src/locales/si.json +0 -143
  177. package/src/locales/ta.json +0 -143
  178. package/src/locales/te.json +0 -143
  179. package/src/navigator/SceneNavigator.tsx +0 -844
  180. package/src/navigator/StatisticsDialog.tsx +0 -249
  181. package/src/navigator/scene-list.ts +0 -84
  182. package/src/settings/SettingsDialog.tsx +0 -303
  183. package/src/shell/AppBar.tsx +0 -354
  184. package/src/shell/AppShell.tsx +0 -71
  185. package/src/shell/ConfirmDialog.tsx +0 -71
  186. package/src/shell/ElementToolbar.tsx +0 -175
  187. package/src/shell/LanguageStatus.tsx +0 -101
  188. package/src/shell/StatusBar.tsx +0 -135
  189. package/src/shell/SutrataLogo.tsx +0 -89
  190. package/src/shell/Toast.tsx +0 -46
  191. package/src/shell/ToolbarButton.tsx +0 -30
  192. package/src/shell/VersionHistory.tsx +0 -212
  193. package/src/shell/Watermark.tsx +0 -9
  194. package/src/shell/fonts/fonts.css +0 -1133
  195. package/src/shell/icons.tsx +0 -335
  196. package/src/shell/toolbar-actions.ts +0 -59
  197. package/src/source/SourceView.tsx +0 -111
  198. package/src/source/find-highlight-source.ts +0 -57
  199. package/src/source/sutra-lang.ts +0 -121
  200. package/src/spellcheck/dictionaries.ts +0 -21
  201. package/src/spellcheck/hunspell.ts +0 -110
  202. package/src/spellcheck/spellcheck-plugin.ts +0 -83
  203. package/src/styles/StyleDialog.tsx +0 -148
  204. package/src/styles/builtin-styles.ts +0 -139
  205. package/src/styles/css-adapter.ts +0 -76
  206. package/src/styles/docx-adapter.ts +0 -109
  207. package/src/styles/embed-fonts.ts +0 -81
  208. package/src/styles/pagination.ts +0 -27
  209. package/src/styles/print-adapter.ts +0 -288
  210. package/src/styles/registry.ts +0 -20
  211. package/src/styles/screenplay.css +0 -3006
  212. package/src/styles/script-ranges.ts +0 -17
  213. package/src/styles/types.ts +0 -104
  214. package/src/styles/validate.ts +0 -114
  215. package/src/templates/templates.ts +0 -194
  216. package/src/titlepage/ExportDialog.tsx +0 -494
  217. package/src/titlepage/TitlePageDialog.tsx +0 -312
  218. package/src/titlepage/frontmatter-form.ts +0 -280
  219. package/src/titlepage/languages.ts +0 -37
  220. package/src/types.ts +0 -7
  221. package/tests/AppBar.test.tsx +0 -35
  222. package/tests/AppShell.test.tsx +0 -22
  223. package/tests/ElementToolbar.test.tsx +0 -36
  224. package/tests/LanguageStatus.test.tsx +0 -129
  225. package/tests/SelectionMenu.test.tsx +0 -71
  226. package/tests/ToolbarButton.test.tsx +0 -24
  227. package/tests/ai-onboarding.test.ts +0 -102
  228. package/tests/ai-status.test.tsx +0 -72
  229. package/tests/ai-structured.test.ts +0 -94
  230. package/tests/autocomplete/AutocompletePlugin.test.ts +0 -20
  231. package/tests/autocomplete/suggestions.test.ts +0 -106
  232. package/tests/document-context.test.tsx +0 -565
  233. package/tests/docx-importer.test.ts +0 -160
  234. package/tests/editor/character-decoration-plugin.test.ts +0 -101
  235. package/tests/editor/character-extension.test.ts +0 -44
  236. package/tests/editor/grapheme-cursor.test.ts +0 -118
  237. package/tests/editor/ime-plugin.test.ts +0 -72
  238. package/tests/editor/inline-note-bridge.test.ts +0 -32
  239. package/tests/editor/language-class-plugin.test.ts +0 -265
  240. package/tests/editor/title-page.test.ts +0 -124
  241. package/tests/embed-fonts.test.ts +0 -17
  242. package/tests/exporters.test.ts +0 -365
  243. package/tests/findreplace/FindReplace.test.tsx +0 -391
  244. package/tests/findreplace/find-engine.test.ts +0 -86
  245. package/tests/findreplace/scroll-to-match.test.ts +0 -50
  246. package/tests/fonts/tofu-detect.test.ts +0 -122
  247. package/tests/i18n/LanguageContext.test.tsx +0 -146
  248. package/tests/i18n/language-detect.test.ts +0 -54
  249. package/tests/i18n/locale-parity.test.ts +0 -25
  250. package/tests/i18n/useTranslation.test.tsx +0 -45
  251. package/tests/keymap.test.ts +0 -18
  252. package/tests/navigator/SceneNavigator.test.tsx +0 -177
  253. package/tests/navigator/StatisticsDialog.test.tsx +0 -63
  254. package/tests/navigator/scene-list.test.ts +0 -71
  255. package/tests/print-paginator.test.ts +0 -82
  256. package/tests/shell/VersionHistory.test.tsx +0 -129
  257. package/tests/spellcheck/hunspell.test.ts +0 -82
  258. package/tests/spellcheck/spellcheck-plugin.test.ts +0 -56
  259. package/tests/styles.test.ts +0 -315
  260. package/tests/templates/templates.test.ts +0 -83
  261. package/tests/titlepage/TitlePageDialog.test.tsx +0 -90
  262. package/tests/titlepage/apply-title-page.test.ts +0 -61
  263. package/tests/titlepage/frontmatter-form.test.ts +0 -115
  264. package/tests/toolbar-actions.test.ts +0 -93
  265. package/tests/voice-commands.test.ts +0 -58
  266. package/tsconfig.json +0 -17
  267. package/vitest.config.ts +0 -11
  268. package/vitest.setup.ts +0 -65
@@ -1,585 +0,0 @@
1
- import {
2
- Document,
3
- Packer,
4
- Paragraph,
5
- TextRun,
6
- Table,
7
- TableRow,
8
- TableCell,
9
- AlignmentType,
10
- BorderStyle,
11
- PageBreak,
12
- WidthType,
13
- TabStopType,
14
- LineRuleType,
15
- Header,
16
- PageNumber,
17
- } from 'docx'
18
- import type { DocumentNode, ContentNode, InlineSpan } from '@sutrata/parser'
19
- import { resolveStyle } from '../styles/registry'
20
- import { styleToDocxParagraphStyles } from '../styles/docx-adapter'
21
- import { SCRIPT_UNICODE_RANGES } from '../styles/script-ranges'
22
- import { DEFAULT_COVER_LAYOUT } from '../styles/types'
23
- import type { ScreenplayStyleDefinition, CoverElementKey } from '../styles/types'
24
-
25
- interface TextRunProps {
26
- text: string
27
- bold?: boolean
28
- italic?: boolean
29
- underline?: boolean
30
- }
31
-
32
- /** Flatten inline span trees into flat formatting runs. `skipNotes` drops inline
33
- * `[[ ]]` note spans entirely (see the "Skip notes" export option). */
34
- function flattenSpans(
35
- spans: InlineSpan[],
36
- props: { bold?: boolean; italic?: boolean; underline?: boolean } = {},
37
- skipNotes = false,
38
- ): TextRunProps[] {
39
- const result: TextRunProps[] = []
40
- for (const s of spans) {
41
- if (s.type === 'text') {
42
- result.push({ text: s.text, ...props })
43
- } else if (s.type === 'bold') {
44
- result.push(...flattenSpans(s.spans, { ...props, bold: true }, skipNotes))
45
- } else if (s.type === 'italic') {
46
- result.push(...flattenSpans(s.spans, { ...props, italic: true }, skipNotes))
47
- } else if (s.type === 'underline') {
48
- result.push(...flattenSpans(s.spans, { ...props, underline: true }, skipNotes))
49
- } else if (s.type === 'note') {
50
- if (skipNotes) continue
51
- // Matches whole-line note styling (italic, brackets kept visible).
52
- result.push({ text: `[[ ${s.text} ]]`, ...props, italic: true })
53
- }
54
- }
55
- return result
56
- }
57
-
58
- const SCRIPT_RANGES: { code: string; re: RegExp }[] = Object.entries(SCRIPT_UNICODE_RANGES).map(
59
- ([code, re]) => ({ code, re })
60
- )
61
- const SCRIPT_FONT_BY_LANG: Record<string, string> = {
62
- hi: 'Noto Sans Devanagari', ta: 'Noto Sans Tamil', te: 'Noto Sans Telugu',
63
- kn: 'Noto Sans Kannada', ml: 'Noto Sans Malayalam', bn: 'Noto Sans Bengali',
64
- gu: 'Noto Sans Gujarati', pa: 'Noto Sans Gurmukhi', or: 'Noto Sans Oriya',
65
- si: 'Noto Sans Sinhala',
66
- }
67
-
68
- /** Which complex-script lang code (if any) a run's text belongs to \u2014 the shared key
69
- * space for both `complexScriptFontOverrides` and `italicScripts`. Unicode presence
70
- * wins over `defaultLang` (mixed-script documents), matching getComplexFont's
71
- * original priority.
72
- *
73
- * The `defaultLang` fallback (for text with no script-specific characters at all,
74
- * e.g. bare punctuation/numbers between Indic words) must never fire for text that
75
- * contains actual Latin letters \u2014 otherwise a document with `lang: ta` would treat
76
- * every English word (scene headings, "EXT.", character extensions, a plain title)
77
- * as Tamil too, which for most styles is a harmless font substitution (Noto Sans/
78
- * Serif Tamil both ship full Latin glyphs) but is actively wrong once a style forces
79
- * italics for that script (Traditional) \u2014 English text would come out italicized
80
- * for no reason. */
81
- function matchScriptCode(text: string, defaultLang: string = 'en'): string | null {
82
- for (const { code, re } of SCRIPT_RANGES) {
83
- if (re.test(text)) return code
84
- }
85
- if (/[A-Za-z]/.test(text)) return null
86
- return defaultLang in SCRIPT_FONT_BY_LANG ? defaultLang : null
87
- }
88
-
89
- /** Resolve a complex-script font override for a matched script code. Returns null when
90
- * the text needs no override (plain Latin) \u2014 never a specific "Latin font" name, since
91
- * that decision belongs to the active style's fontFamily (applied at the paragraph-style
92
- * level). `overrides` is a style's optional `complexScriptFontOverrides` (e.g.
93
- * Traditional's Tamil -> Noto Serif Tamil) \u2014 DOCX runs can only ever name one font,
94
- * unlike a CSS font stack, so a style whose body font *does* cover a script it cares
95
- * about must say so explicitly here rather than relying on per-glyph fallback like the
96
- * editor/print CSS do. */
97
- function getComplexFont(code: string | null, overrides?: Record<string, string>): string | null {
98
- if (!code) return null
99
- return overrides?.[code] ?? SCRIPT_FONT_BY_LANG[code] ?? null
100
- }
101
-
102
- /** Generate docx TextRun list with correct font mapping. `italicScripts` (a style's
103
- * per-script "italic is this script's standard look here" list, e.g. Traditional's
104
- * Tamil) forces italics regardless of the run's own markup or `forceItalics` \u2014 matches
105
- * the editor's `.cs-force-italic-<code>` behavior. */
106
- function createRuns(
107
- runProps: TextRunProps[],
108
- defaultLang: string,
109
- forceItalics = false,
110
- complexScriptFontOverrides?: Record<string, string>,
111
- italicScripts?: string[],
112
- ): TextRun[] {
113
- return runProps.map(r => {
114
- const code = matchScriptCode(r.text, defaultLang)
115
- const csFont = getComplexFont(code, complexScriptFontOverrides)
116
- const scriptForcesItalic = code !== null && (italicScripts?.includes(code) ?? false)
117
- const runOptions: any = {
118
- text: r.text,
119
- bold: r.bold,
120
- italics: r.italic || forceItalics || scriptForcesItalic,
121
- underline: r.underline ? {} : undefined,
122
- }
123
-
124
- // Only set font override for Indic scripts to support proper rendering,
125
- // leaving latin/general characters to use the paragraph style's own font.
126
- if (csFont) {
127
- runOptions.font = {
128
- ascii: csFont,
129
- hAnsi: csFont,
130
- cs: csFont,
131
- eastAsia: csFont,
132
- }
133
- }
134
-
135
- return new TextRun(runOptions)
136
- })
137
- }
138
-
139
- /** Vertical placement of the 'corners' cover, as fractions of the printable page height:
140
- * where the title block starts and where the bottom-right date/contact block starts.
141
- * Matches print-adapter.ts's .print-cover-corners padding (20% top / block sitting ~6%
142
- * above the bottom edge), so the DOCX and PDF covers land in the same place. */
143
- const COVER_CORNERS_TOP_FRACTION = 0.22
144
- const COVER_CORNERS_BOTTOM_FRACTION = 0.78
145
-
146
- /** DOCX has no way to anchor a block to the bottom of a page, so the corner cover is
147
- * positioned by stacking empty paragraphs of an exact, known height (rather than
148
- * Normal-styled blank ones, whose height depends on the reader's default font). */
149
- const COVER_SPACER_TWIPS = 240 // 12pt
150
-
151
- /** Height budgeted per date/contact line — their run sizes (10pt/11pt) plus leading. */
152
- const COVER_CONTACT_LINE_TWIPS = 280
153
-
154
- /** Enough exact-height spacers to cover `twips` (never negative — a block that already
155
- * overshoots its target simply gets none). */
156
- function spacerParagraphs(twips: number): Paragraph[] {
157
- const count = Math.max(0, Math.round(twips / COVER_SPACER_TWIPS))
158
- return Array.from({ length: count }, () => new Paragraph({
159
- spacing: { before: 0, after: 0, line: COVER_SPACER_TWIPS, lineRule: LineRuleType.EXACT },
160
- }))
161
- }
162
-
163
- /** Approximate rendered height of a run of cover paragraphs, from their style's font
164
- * sizes and spacing. Approximate is enough: it only decides how many spacers separate
165
- * the two corner blocks, and the bottom block's placement is clamped so an
166
- * underestimate can't push it onto a second page. Assumes each element occupies one
167
- * line — a title or logline long enough to wrap just rides slightly lower. */
168
- function estimateBlockHeightTwips(style: ScreenplayStyleDefinition, keys: CoverElementKey[]): number {
169
- return keys.reduce((sum, key) => {
170
- const el = style.elements[key]
171
- return sum + Math.round(el.fontSizePt * 1.15 * 20) + Math.round((el.spaceBeforePt ?? 0) * 20)
172
- + Math.round((el.spaceAfterPt ?? 0) * 20)
173
- }, 0)
174
- }
175
-
176
- /** Main exporter function. `styleOverride` picks a style id for this export only
177
- * (without touching the document); otherwise the document's frontmatter `style:`
178
- * field is used, falling back to the default style. `customStyles` is the list of
179
- * user-imported styles loaded from IndexedDB (see file/storage.ts). `skipNotes` omits
180
- * both block-level and inline `[[ ]]` notes from the exported document. */
181
- export async function exportToDocx(
182
- ast: DocumentNode,
183
- styleOverride?: string,
184
- customStyles: ScreenplayStyleDefinition[] = [],
185
- skipNotes = false,
186
- ): Promise<Blob> {
187
- const frontmatter = ast.frontmatter?.data as Record<string, unknown> | undefined
188
- const defaultLang = (frontmatter?.['lang'] as string) ?? 'en'
189
- const style = resolveStyle(styleOverride ?? (frontmatter?.['style'] as string | undefined), customStyles)
190
- const bodyFont = style.fontFamily
191
- const runsFor = (props: TextRunProps[], forceItalics = false) =>
192
- createRuns(props, defaultLang, forceItalics, style.complexScriptFontOverrides, style.italicScripts)
193
- const pageFormat = (frontmatter?.['page'] as string)?.toLowerCase() ?? 'letter'
194
- const pageWidthTwips = pageFormat === 'a4' ? 11906 : 12240
195
- const pageHeightTwips = pageFormat === 'a4' ? 16838 : 15840
196
- const pageMarginLeftTwips = Math.round(style.page.marginLeftIn * 1440)
197
- const pageMarginRightTwips = Math.round(style.page.marginRightIn * 1440)
198
- const pageMarginTopTwips = Math.round(style.page.marginTopIn * 1440)
199
- const pageMarginBottomTwips = Math.round(style.page.marginBottomIn * 1440)
200
- // Right edge of the printable area, measured from the page margin — where
201
- // a scene number's right-aligned tab stop should land (matches the UI's
202
- // right-aligned scene-number box, no leading "#").
203
- const contentRightEdgeTwips = pageWidthTwips - pageMarginLeftTwips - pageMarginRightTwips
204
-
205
- const childrenNodes: (Paragraph | Table)[] = []
206
-
207
- const hasCover = Boolean(frontmatter && (frontmatter['title'] || frontmatter['author']))
208
-
209
- // 1. Cover Page (if frontmatter has title)
210
- if (hasCover && frontmatter) {
211
- const titleText = String(frontmatter['title'] || 'Untitled')
212
- const authorText = String(frontmatter['author'] || '')
213
- const loglineText = String(frontmatter['logline'] || '')
214
- const dateText = String(frontmatter['date'] || '')
215
- const contactText = String(frontmatter['contact'] || '')
216
-
217
- const titlePara = new Paragraph({
218
- style: 'CineTitle',
219
- children: runsFor([{ text: titleText.toUpperCase(), bold: true }]),
220
- })
221
- const loglineParas = loglineText
222
- ? loglineText.split('\n').map(line => new Paragraph({
223
- style: 'CineLogline',
224
- children: runsFor([{ text: line, italic: true }]),
225
- }))
226
- : []
227
- // Deliberately not the author style: these mirror the PDF cover's small gray
228
- // date/contact lines (.print-cover-date / .print-cover-contact), not the
229
- // author's display size.
230
- const dateRun = (indent?: number) => new Paragraph({
231
- alignment: indent === undefined ? AlignmentType.CENTER : undefined,
232
- indent: indent === undefined ? undefined : { left: indent },
233
- children: [new TextRun({ text: dateText, font: bodyFont, size: 20, color: '777777' })],
234
- })
235
- const contactRuns = (indent?: number) => contactText.split('\n').map(line => new Paragraph({
236
- alignment: indent === undefined ? AlignmentType.CENTER : undefined,
237
- indent: indent === undefined ? undefined : { left: indent },
238
- children: [new TextRun({ text: line, font: bodyFont, size: 22, color: '888888' })],
239
- }))
240
-
241
- if ((style.coverLayout ?? DEFAULT_COVER_LAYOUT) === 'corners') {
242
- // Title / "by <author>" / logline flush top-left, date + contact in the
243
- // bottom-right corner — the PDF cover's .print-cover-corners layout, rebuilt
244
- // out of the only vertical tool DOCX gives us: exact-height spacer paragraphs
245
- // (see spacerParagraphs) counted off against the printable page height.
246
- const contentHeightTwips = pageHeightTwips - pageMarginTopTwips - pageMarginBottomTwips
247
- const topBlock = [
248
- titlePara,
249
- ...(authorText ? [new Paragraph({
250
- style: 'CineAuthor',
251
- children: runsFor([{ text: `by ${authorText}` }]),
252
- })] : []),
253
- ...loglineParas,
254
- ]
255
- const bottomIndent = Math.round(contentRightEdgeTwips * 0.55)
256
- const bottomBlock = [
257
- ...(dateText ? [dateRun(bottomIndent)] : []),
258
- ...(contactText ? contactRuns(bottomIndent) : []),
259
- ]
260
-
261
- const topOffsetTwips = Math.round(contentHeightTwips * COVER_CORNERS_TOP_FRACTION)
262
- const topSpacers = spacerParagraphs(topOffsetTwips)
263
- childrenNodes.push(...topSpacers, ...topBlock)
264
-
265
- // Fill down to the bottom block's target, but never past the point where the
266
- // block itself would no longer fit — an overshoot would spill the corner onto
267
- // a second cover page.
268
- const usedTwips = topSpacers.length * COVER_SPACER_TWIPS
269
- + estimateBlockHeightTwips(style, ['title', ...(authorText ? ['author' as const] : []), ...loglineParas.map(() => 'logline' as const)])
270
- const bottomHeightTwips = bottomBlock.length * COVER_CONTACT_LINE_TWIPS
271
- const bottomTopTwips = Math.min(
272
- Math.round(contentHeightTwips * COVER_CORNERS_BOTTOM_FRACTION),
273
- contentHeightTwips - bottomHeightTwips,
274
- )
275
- childrenNodes.push(...spacerParagraphs(bottomTopTwips - usedTwips), ...bottomBlock)
276
- } else {
277
- for (let k = 0; k < 12; k++) {
278
- childrenNodes.push(new Paragraph({ text: '' }))
279
- }
280
-
281
- childrenNodes.push(titlePara)
282
-
283
- childrenNodes.push(new Paragraph({ text: '' }))
284
- childrenNodes.push(
285
- new Paragraph({
286
- alignment: AlignmentType.CENTER,
287
- children: [new TextRun({ text: 'by', font: bodyFont, size: 24 /* 12pt */ })],
288
- })
289
- )
290
- childrenNodes.push(new Paragraph({ text: '' }))
291
-
292
- childrenNodes.push(
293
- new Paragraph({
294
- style: 'CineAuthor',
295
- children: runsFor([{ text: authorText }]),
296
- })
297
- )
298
-
299
- if (loglineParas.length > 0) {
300
- for (let k = 0; k < 6; k++) childrenNodes.push(new Paragraph({ text: '' }))
301
- childrenNodes.push(...loglineParas)
302
- }
303
-
304
- if (dateText) {
305
- for (let k = 0; k < 10; k++) childrenNodes.push(new Paragraph({ text: '' }))
306
- childrenNodes.push(dateRun())
307
- }
308
-
309
- if (contactText) {
310
- if (!dateText) {
311
- for (let k = 0; k < 10; k++) childrenNodes.push(new Paragraph({ text: '' }))
312
- } else {
313
- childrenNodes.push(new Paragraph({ text: '' }))
314
- childrenNodes.push(new Paragraph({ text: '' }))
315
- }
316
- childrenNodes.push(...contactRuns())
317
- }
318
- }
319
-
320
- childrenNodes.push(new Paragraph({ children: [new PageBreak()] }))
321
- }
322
-
323
- // Helper to process standard scene content node and return array of docx components
324
- function processNode(n: ContentNode): (Paragraph | Table)[] {
325
- if (n.type === 'action') {
326
- const runs = runsFor(flattenSpans(n.spans, {}, skipNotes))
327
- return [
328
- new Paragraph({
329
- style: 'CineAction',
330
- children: runs,
331
- }),
332
- ]
333
- }
334
-
335
- if (n.type === 'character') {
336
- const charName = n.name.toUpperCase()
337
- const ext = n.extension ? ` ${n.extension.toUpperCase()}` : ''
338
- const headText = `${charName}${ext}`
339
- const runs = runsFor([{ text: headText, bold: true }])
340
-
341
- const result: (Paragraph | Table)[] = [
342
- new Paragraph({
343
- style: 'CineCharacter',
344
- children: runs,
345
- }),
346
- ]
347
-
348
- for (const c of n.children) {
349
- if (c.type === 'parenthetical') {
350
- const pText = `(${c.text})`
351
- result.push(
352
- new Paragraph({
353
- style: 'CineParenthetical',
354
- children: runsFor([{ text: pText }]),
355
- })
356
- )
357
- } else {
358
- const dialogueRuns = runsFor(flattenSpans(c.spans, {}, skipNotes))
359
- result.push(
360
- new Paragraph({
361
- style: 'CineDialogue',
362
- children: dialogueRuns,
363
- })
364
- )
365
- }
366
- }
367
- return result
368
- }
369
-
370
- if (n.type === 'transition') {
371
- const transText = n.text.toUpperCase()
372
- return [
373
- new Paragraph({
374
- style: 'CineTransition',
375
- children: runsFor([{ text: transText }]),
376
- }),
377
- ]
378
- }
379
-
380
- if (n.type === 'centered') {
381
- return [
382
- new Paragraph({
383
- style: 'CineCentered',
384
- children: runsFor([{ text: n.text }]),
385
- }),
386
- ]
387
- }
388
-
389
- if (n.type === 'lyrics') {
390
- const runs = runsFor(flattenSpans(n.spans, {}, skipNotes), true)
391
- return [
392
- new Paragraph({
393
- style: 'CineLyrics',
394
- children: runs,
395
- }),
396
- ]
397
- }
398
-
399
- if (n.type === 'note') {
400
- if (skipNotes) return []
401
- const noteText = `[[ ${n.text} ]]`
402
- return [
403
- new Paragraph({
404
- style: 'CineNote',
405
- children: runsFor([{ text: noteText, italic: true }]),
406
- }),
407
- ]
408
- }
409
-
410
- if (n.type === 'comment') {
411
- const commentText = `<!-- ${n.text} -->`
412
- return [
413
- new Paragraph({
414
- style: 'CineComment',
415
- children: runsFor([{ text: commentText, italic: true }]),
416
- }),
417
- ]
418
- }
419
-
420
- if (n.type === 'page-break') {
421
- return [new Paragraph({ children: [new PageBreak()] })]
422
- }
423
-
424
- if (n.type === 'dual-dialogue') {
425
- const leftParas: Paragraph[] = []
426
- const leftChar = n.left
427
- const leftRuns = runsFor([{ text: `${leftChar.name.toUpperCase()}${leftChar.extension ? ` ${leftChar.extension.toUpperCase()}` : ''}`, bold: true }])
428
- leftParas.push(new Paragraph({ style: 'CineCharacter', children: leftRuns }))
429
- for (const c of leftChar.children) {
430
- if (c.type === 'parenthetical') {
431
- leftParas.push(new Paragraph({ style: 'CineParenthetical', children: runsFor([{ text: `(${c.text})` }]) }))
432
- } else {
433
- leftParas.push(
434
- new Paragraph({
435
- style: 'CineDialogue',
436
- children: runsFor(flattenSpans(c.spans, {}, skipNotes)),
437
- })
438
- )
439
- }
440
- }
441
-
442
- const rightParas: Paragraph[] = []
443
- const rightChar = n.right
444
- const rightRuns = runsFor([{ text: `${rightChar.name.toUpperCase()}${rightChar.extension ? ` ${rightChar.extension.toUpperCase()}` : ''}`, bold: true }])
445
- rightParas.push(new Paragraph({ style: 'CineCharacter', children: rightRuns }))
446
- for (const c of rightChar.children) {
447
- if (c.type === 'parenthetical') {
448
- rightParas.push(new Paragraph({ style: 'CineParenthetical', children: runsFor([{ text: `(${c.text})` }]) }))
449
- } else {
450
- rightParas.push(
451
- new Paragraph({
452
- style: 'CineDialogue',
453
- children: runsFor(flattenSpans(c.spans, {}, skipNotes)),
454
- })
455
- )
456
- }
457
- }
458
-
459
- return [
460
- new Table({
461
- width: { size: 100, type: WidthType.PERCENTAGE },
462
- borders: {
463
- top: { style: BorderStyle.NONE, size: 0, color: 'auto' },
464
- bottom: { style: BorderStyle.NONE, size: 0, color: 'auto' },
465
- left: { style: BorderStyle.NONE, size: 0, color: 'auto' },
466
- right: { style: BorderStyle.NONE, size: 0, color: 'auto' },
467
- insideHorizontal: { style: BorderStyle.NONE, size: 0, color: 'auto' },
468
- insideVertical: { style: BorderStyle.NONE, size: 0, color: 'auto' },
469
- },
470
- rows: [
471
- new TableRow({
472
- children: [
473
- new TableCell({
474
- children: leftParas,
475
- width: { size: 50, type: WidthType.PERCENTAGE },
476
- }),
477
- new TableCell({
478
- children: rightParas,
479
- width: { size: 50, type: WidthType.PERCENTAGE },
480
- }),
481
- ],
482
- }),
483
- ],
484
- }),
485
- ]
486
- }
487
-
488
- return []
489
- }
490
-
491
- // Iterate over children
492
- for (const node of ast.children) {
493
- if (node.type === 'section') {
494
- const sectText = node.text
495
- childrenNodes.push(
496
- new Paragraph({
497
- style: 'CineSection',
498
- children: runsFor([{ text: sectText, bold: true }]),
499
- })
500
- )
501
- for (const subNode of node.children) {
502
- childrenNodes.push(...processNode(subNode))
503
- }
504
- } else if (node.type === 'scene-heading') {
505
- const headingRuns = runsFor([{ text: node.text.toUpperCase(), bold: true }])
506
- if (node.id) {
507
- headingRuns.push(
508
- new TextRun({ text: '\t' }),
509
- ...runsFor([{ text: node.id, bold: true }])
510
- )
511
- }
512
- childrenNodes.push(
513
- new Paragraph({
514
- style: 'CineSceneHeading',
515
- tabStops: node.id
516
- ? [{ type: TabStopType.RIGHT, position: contentRightEdgeTwips }]
517
- : undefined,
518
- children: headingRuns,
519
- })
520
- )
521
- for (const subNode of node.children) {
522
- childrenNodes.push(...processNode(subNode))
523
- }
524
- } else {
525
- childrenNodes.push(...processNode(node))
526
- }
527
- }
528
-
529
- // Running header: "p<n>" in the top right, as a PAGE field so Word keeps it correct as
530
- // the document is edited. The PDF export's other header cell — "<scene> CONTINUED:" on a
531
- // page that opens mid-scene — has no DOCX equivalent: it depends on where the page break
532
- // falls, and Word neither exposes that to a field nor lets a header vary per page within
533
- // a section. (Only the cover is special-cased, via titlePage below.)
534
- const runningHeader = new Header({
535
- children: [
536
- new Paragraph({
537
- alignment: AlignmentType.RIGHT,
538
- children: [
539
- new TextRun({
540
- children: ['p', PageNumber.CURRENT],
541
- font: bodyFont,
542
- size: Math.round(style.elements.action.fontSizePt * 2),
543
- italics: true,
544
- }),
545
- ],
546
- }),
547
- ],
548
- })
549
-
550
- // Create document with screenplay styles driven by the resolved ScreenplayStyleDefinition
551
- const doc = new Document({
552
- styles: {
553
- paragraphStyles: styleToDocxParagraphStyles(style, contentRightEdgeTwips),
554
- },
555
- sections: [
556
- {
557
- properties: {
558
- // The cover takes Word's "different first page" header (an empty one) and page
559
- // number 0, so the first page of the script itself comes out as p1.
560
- titlePage: hasCover,
561
- page: {
562
- size: {
563
- width: pageWidthTwips,
564
- height: pageHeightTwips,
565
- },
566
- margin: {
567
- top: pageMarginTopTwips,
568
- bottom: pageMarginBottomTwips,
569
- left: pageMarginLeftTwips,
570
- right: pageMarginRightTwips,
571
- },
572
- pageNumbers: hasCover ? { start: 0 } : undefined,
573
- },
574
- },
575
- headers: {
576
- default: runningHeader,
577
- ...(hasCover ? { first: new Header({ children: [new Paragraph({ text: '' })] }) } : {}),
578
- },
579
- children: childrenNodes,
580
- },
581
- ],
582
- })
583
-
584
- return Packer.toBlob(doc)
585
- }