@sutrata/editor 0.1.0 → 0.1.2

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 (152) hide show
  1. package/README.md +87 -0
  2. package/dist/ai/generated-providers.json +1 -1
  3. package/package.json +36 -9
  4. package/scripts/copy-assets.mjs +0 -16
  5. package/scripts/generate-providers.mjs +0 -127
  6. package/src/ai/OnboardingDialog.tsx +0 -390
  7. package/src/ai/VoiceConfirmDialog.tsx +0 -137
  8. package/src/ai/VoiceToolbar.tsx +0 -321
  9. package/src/ai/ai-client.ts +0 -596
  10. package/src/ai/commands.ts +0 -164
  11. package/src/ai/generated-providers.json +0 -3696
  12. package/src/ai/prompts.ts +0 -62
  13. package/src/ai/voice-service.ts +0 -96
  14. package/src/autocomplete/AutocompletePlugin.ts +0 -21
  15. package/src/autocomplete/suggestions.ts +0 -147
  16. package/src/context/DocumentContext.tsx +0 -648
  17. package/src/desktop-api.d.ts +0 -40
  18. package/src/editor/EditorView.tsx +0 -82
  19. package/src/editor/SelectionMenu.tsx +0 -152
  20. package/src/editor/attr-pencil-plugin.ts +0 -172
  21. package/src/editor/character-decoration-plugin.ts +0 -104
  22. package/src/editor/editor-bus.ts +0 -51
  23. package/src/editor/find-highlight-plugin.ts +0 -68
  24. package/src/editor/frontmatter-field-view.ts +0 -52
  25. package/src/editor/frontmatter-field.ts +0 -115
  26. package/src/editor/grapheme-cursor.ts +0 -99
  27. package/src/editor/ime-plugin.ts +0 -85
  28. package/src/editor/insert-helper.ts +0 -43
  29. package/src/editor/keymap.ts +0 -124
  30. package/src/editor/language-class-plugin.ts +0 -91
  31. package/src/editor/plugins.ts +0 -26
  32. package/src/editor/prosemirror-to-sutra.ts +0 -192
  33. package/src/editor/scene-heading-view.ts +0 -147
  34. package/src/editor/schema.ts +0 -156
  35. package/src/editor/sutra-to-prosemirror.ts +0 -156
  36. package/src/extensions/storage-adapter.ts +0 -65
  37. package/src/extensions/types.ts +0 -60
  38. package/src/file/docx-exporter.ts +0 -585
  39. package/src/file/docx-importer.ts +0 -313
  40. package/src/file/print-paginator.ts +0 -301
  41. package/src/file/templates.ts +0 -73
  42. package/src/file/workflow-reports.ts +0 -820
  43. package/src/findreplace/FindReplace.tsx +0 -242
  44. package/src/findreplace/find-engine.ts +0 -97
  45. package/src/findreplace/scroll-to-match.ts +0 -89
  46. package/src/fonts/tofu-detect.ts +0 -53
  47. package/src/i18n/LanguageContext.tsx +0 -207
  48. package/src/i18n/language-detect.ts +0 -73
  49. package/src/i18n/useTranslation.tsx +0 -74
  50. package/src/index.ts +0 -27
  51. package/src/locales/bn.json +0 -143
  52. package/src/locales/en.json +0 -143
  53. package/src/locales/gu.json +0 -143
  54. package/src/locales/hi.json +0 -143
  55. package/src/locales/kn.json +0 -143
  56. package/src/locales/ml.json +0 -143
  57. package/src/locales/mr.json +0 -143
  58. package/src/locales/or.json +0 -143
  59. package/src/locales/pa.json +0 -143
  60. package/src/locales/si.json +0 -143
  61. package/src/locales/ta.json +0 -143
  62. package/src/locales/te.json +0 -143
  63. package/src/navigator/SceneNavigator.tsx +0 -844
  64. package/src/navigator/StatisticsDialog.tsx +0 -249
  65. package/src/navigator/scene-list.ts +0 -84
  66. package/src/settings/SettingsDialog.tsx +0 -303
  67. package/src/shell/AppBar.tsx +0 -354
  68. package/src/shell/AppShell.tsx +0 -71
  69. package/src/shell/ConfirmDialog.tsx +0 -71
  70. package/src/shell/ElementToolbar.tsx +0 -175
  71. package/src/shell/LanguageStatus.tsx +0 -101
  72. package/src/shell/StatusBar.tsx +0 -135
  73. package/src/shell/SutrataLogo.tsx +0 -89
  74. package/src/shell/Toast.tsx +0 -46
  75. package/src/shell/ToolbarButton.tsx +0 -30
  76. package/src/shell/VersionHistory.tsx +0 -212
  77. package/src/shell/Watermark.tsx +0 -9
  78. package/src/shell/fonts/fonts.css +0 -1133
  79. package/src/shell/icons.tsx +0 -335
  80. package/src/shell/toolbar-actions.ts +0 -59
  81. package/src/source/SourceView.tsx +0 -111
  82. package/src/source/find-highlight-source.ts +0 -57
  83. package/src/source/sutra-lang.ts +0 -121
  84. package/src/spellcheck/dictionaries.ts +0 -21
  85. package/src/spellcheck/hunspell.ts +0 -110
  86. package/src/spellcheck/spellcheck-plugin.ts +0 -83
  87. package/src/styles/StyleDialog.tsx +0 -148
  88. package/src/styles/builtin-styles.ts +0 -139
  89. package/src/styles/css-adapter.ts +0 -76
  90. package/src/styles/docx-adapter.ts +0 -109
  91. package/src/styles/embed-fonts.ts +0 -81
  92. package/src/styles/pagination.ts +0 -27
  93. package/src/styles/print-adapter.ts +0 -288
  94. package/src/styles/registry.ts +0 -20
  95. package/src/styles/screenplay.css +0 -3006
  96. package/src/styles/script-ranges.ts +0 -17
  97. package/src/styles/types.ts +0 -104
  98. package/src/styles/validate.ts +0 -114
  99. package/src/templates/templates.ts +0 -194
  100. package/src/titlepage/ExportDialog.tsx +0 -494
  101. package/src/titlepage/TitlePageDialog.tsx +0 -312
  102. package/src/titlepage/frontmatter-form.ts +0 -280
  103. package/src/titlepage/languages.ts +0 -37
  104. package/src/types.ts +0 -7
  105. package/tests/AppBar.test.tsx +0 -35
  106. package/tests/AppShell.test.tsx +0 -22
  107. package/tests/ElementToolbar.test.tsx +0 -36
  108. package/tests/LanguageStatus.test.tsx +0 -129
  109. package/tests/SelectionMenu.test.tsx +0 -71
  110. package/tests/ToolbarButton.test.tsx +0 -24
  111. package/tests/ai-onboarding.test.ts +0 -102
  112. package/tests/ai-status.test.tsx +0 -72
  113. package/tests/ai-structured.test.ts +0 -94
  114. package/tests/autocomplete/AutocompletePlugin.test.ts +0 -20
  115. package/tests/autocomplete/suggestions.test.ts +0 -106
  116. package/tests/document-context.test.tsx +0 -565
  117. package/tests/docx-importer.test.ts +0 -160
  118. package/tests/editor/character-decoration-plugin.test.ts +0 -101
  119. package/tests/editor/character-extension.test.ts +0 -44
  120. package/tests/editor/grapheme-cursor.test.ts +0 -118
  121. package/tests/editor/ime-plugin.test.ts +0 -72
  122. package/tests/editor/inline-note-bridge.test.ts +0 -32
  123. package/tests/editor/language-class-plugin.test.ts +0 -265
  124. package/tests/editor/title-page.test.ts +0 -124
  125. package/tests/embed-fonts.test.ts +0 -17
  126. package/tests/exporters.test.ts +0 -365
  127. package/tests/findreplace/FindReplace.test.tsx +0 -391
  128. package/tests/findreplace/find-engine.test.ts +0 -86
  129. package/tests/findreplace/scroll-to-match.test.ts +0 -50
  130. package/tests/fonts/tofu-detect.test.ts +0 -122
  131. package/tests/i18n/LanguageContext.test.tsx +0 -146
  132. package/tests/i18n/language-detect.test.ts +0 -54
  133. package/tests/i18n/locale-parity.test.ts +0 -25
  134. package/tests/i18n/useTranslation.test.tsx +0 -45
  135. package/tests/keymap.test.ts +0 -18
  136. package/tests/navigator/SceneNavigator.test.tsx +0 -177
  137. package/tests/navigator/StatisticsDialog.test.tsx +0 -63
  138. package/tests/navigator/scene-list.test.ts +0 -71
  139. package/tests/print-paginator.test.ts +0 -82
  140. package/tests/shell/VersionHistory.test.tsx +0 -129
  141. package/tests/spellcheck/hunspell.test.ts +0 -82
  142. package/tests/spellcheck/spellcheck-plugin.test.ts +0 -56
  143. package/tests/styles.test.ts +0 -315
  144. package/tests/templates/templates.test.ts +0 -83
  145. package/tests/titlepage/TitlePageDialog.test.tsx +0 -90
  146. package/tests/titlepage/apply-title-page.test.ts +0 -61
  147. package/tests/titlepage/frontmatter-form.test.ts +0 -115
  148. package/tests/toolbar-actions.test.ts +0 -93
  149. package/tests/voice-commands.test.ts +0 -58
  150. package/tsconfig.json +0 -17
  151. package/vitest.config.ts +0 -11
  152. package/vitest.setup.ts +0 -65
@@ -1,820 +0,0 @@
1
- import {
2
- Document,
3
- Packer,
4
- Paragraph,
5
- TextRun,
6
- Table,
7
- TableRow,
8
- TableCell,
9
- WidthType,
10
- } from 'docx'
11
- import type { DocumentNode, SceneHeadingNode, ContentNode } from '@sutrata/parser'
12
- import { resolveStyle } from '../styles/registry'
13
- import { styleToPrintCss } from '../styles/print-adapter'
14
- import { embedFontFacesFor } from '../styles/embed-fonts'
15
- import { SCRIPT_UNICODE_RANGES } from '../styles/script-ranges'
16
- import { printPaginatorScript } from './print-paginator'
17
- // CSS absolute units are fixed at 96px/in, print included — so a page box sized in px
18
- // from the paper's inches lands exactly on one sheet.
19
- const CSS_PX_PER_IN = 96
20
- import { DEFAULT_COVER_LAYOUT } from '../styles/types'
21
- import type { ScreenplayStyleDefinition } from '../styles/types'
22
-
23
- // CSV Escape Helper
24
- function esc(val: string): string {
25
- const str = val ? String(val) : ''
26
- return `"${str.replace(/"/g, '""')}"`
27
- }
28
-
29
- export interface WorkflowSceneData {
30
- number: string
31
- heading: string
32
- synopsis: string
33
- actors: string
34
- status: string
35
- estDuration: string
36
- shots: string[]
37
- }
38
-
39
- export interface CharacterStats {
40
- name: string
41
- actor: string
42
- sceneCount: number
43
- scenes: string[]
44
- }
45
-
46
- /** Extract all relevant workflow data from AST */
47
- export function extractWorkflowData(ast: DocumentNode): {
48
- scenes: WorkflowSceneData[]
49
- characters: CharacterStats[]
50
- } {
51
- const scenes: WorkflowSceneData[] = []
52
- const charMap = new Map<string, { sceneCount: number; scenes: string[] }>()
53
-
54
- // Resolve character registry from frontmatter
55
- const fmCharacters = (ast.frontmatter?.data as Record<string, unknown> | undefined)?.[
56
- 'characters'
57
- ] as Record<string, string> | undefined
58
-
59
- let sceneCount = 0
60
- for (const node of ast.children) {
61
- if (node.type === 'scene-heading') {
62
- sceneCount++
63
- const rawHeading = node.text.toUpperCase()
64
-
65
- // Find metadata keys
66
- const numMeta = node.metadata.find(m => m.key === 'number')?.value
67
- const sceneNum = numMeta || node.id || String(sceneCount)
68
- const synopsis = node.metadata.find(m => m.key === 'synopsis')?.value ?? ''
69
- const actors = node.metadata.find(m => m.key === 'actors')?.value ?? ''
70
- const status = node.metadata.find(m => m.key === 'status')?.value ?? ''
71
- const estDuration = node.metadata.find(m => m.key === 'est-duration')?.value ?? ''
72
-
73
- // Collect all shot metadata lines
74
- const shotLines = node.metadata.filter(m => m.key === 'shots').map(m => m.value)
75
-
76
- scenes.push({
77
- number: sceneNum,
78
- heading: rawHeading,
79
- synopsis,
80
- actors,
81
- status,
82
- estDuration,
83
- shots: shotLines,
84
- })
85
-
86
- // Walk this scene's children to find characters
87
- const sceneLabel = `${sceneNum}: ${rawHeading}`
88
- const seenChars = new Set<string>()
89
-
90
- const checkChar = (charName: string) => {
91
- const name = charName.trim().toUpperCase()
92
- if (!name) return
93
- if (seenChars.has(name)) return
94
- seenChars.add(name)
95
-
96
- if (!charMap.has(name)) {
97
- charMap.set(name, { sceneCount: 0, scenes: [] })
98
- }
99
- const stats = charMap.get(name)!
100
- stats.sceneCount++
101
- stats.scenes.push(sceneLabel)
102
- }
103
-
104
- for (const sc of node.children) {
105
- if (sc.type === 'character') {
106
- checkChar(sc.name)
107
- } else if (sc.type === 'dual-dialogue') {
108
- checkChar(sc.left.name)
109
- checkChar(sc.right.name)
110
- }
111
- }
112
- }
113
- }
114
-
115
- // Build character list
116
- const characters: CharacterStats[] = []
117
- for (const [name, stats] of charMap.entries()) {
118
- const actor = fmCharacters?.[name] || fmCharacters?.[name.toLowerCase()] || ''
119
- characters.push({
120
- name,
121
- actor,
122
- sceneCount: stats.sceneCount,
123
- scenes: stats.scenes,
124
- })
125
- }
126
-
127
- // Sort characters by occurrence count descending
128
- characters.sort((a, b) => b.sceneCount - a.sceneCount)
129
-
130
- return { scenes, characters }
131
- }
132
-
133
- // ---------------------------------------------------------
134
- // CSV Exporters
135
- // ---------------------------------------------------------
136
-
137
- export function exportOneLinerCsv(scenes: WorkflowSceneData[]): string {
138
- const header = 'Scene #,Heading,Synopsis,Actors,Status,Est-Duration\n'
139
- const rows = scenes
140
- .map(
141
- s =>
142
- `${esc(s.number)},${esc(s.heading)},${esc(s.synopsis)},${esc(s.actors)},${esc(s.status)},${esc(s.estDuration)}`
143
- )
144
- .join('\n')
145
- return header + rows
146
- }
147
-
148
- export function exportShotListCsv(scenes: WorkflowSceneData[]): string {
149
- const header = 'Scene #,Heading,Actors,Shot Details\n'
150
- const rows: string[] = []
151
- for (const s of scenes) {
152
- if (s.shots.length === 0) {
153
- rows.push(`${esc(s.number)},${esc(s.heading)},${esc(s.actors)},""`)
154
- } else {
155
- for (const shot of s.shots) {
156
- rows.push(`${esc(s.number)},${esc(s.heading)},${esc(s.actors)},${esc(shot)}`)
157
- }
158
- }
159
- }
160
- return header + rows.join('\n')
161
- }
162
-
163
- export function exportCastReportCsv(characters: CharacterStats[]): string {
164
- const header = 'Character,Actor Name,Scene Occurrences,Scenes List\n'
165
- const rows = characters
166
- .map(
167
- c =>
168
- `${esc(c.name)},${esc(c.actor)},${c.sceneCount},${esc(c.scenes.join('; '))}`
169
- )
170
- .join('\n')
171
- return header + rows
172
- }
173
-
174
- // ---------------------------------------------------------
175
- // DOCX Table Exporters
176
- // ---------------------------------------------------------
177
-
178
- function makeCell(text: string, bold = false, widthPercent = 0): TableCell {
179
- return new TableCell({
180
- width: widthPercent > 0 ? { size: widthPercent, type: WidthType.PERCENTAGE } : undefined,
181
- children: [
182
- new Paragraph({
183
- children: [
184
- new TextRun({
185
- text,
186
- bold,
187
- font: 'Courier Prime',
188
- size: '10pt',
189
- }),
190
- ],
191
- }),
192
- ],
193
- })
194
- }
195
-
196
- export async function exportOneLinerDocx(scenes: WorkflowSceneData[]): Promise<Blob> {
197
- const headerRow = new TableRow({
198
- children: [
199
- makeCell('Scene #', true, 10),
200
- makeCell('Heading', true, 25),
201
- makeCell('Synopsis', true, 35),
202
- makeCell('Actors', true, 15),
203
- makeCell('Status', true, 10),
204
- makeCell('Duration', true, 5),
205
- ],
206
- })
207
-
208
- const rows = [headerRow]
209
- for (const s of scenes) {
210
- rows.push(
211
- new TableRow({
212
- children: [
213
- makeCell(s.number, false, 10),
214
- makeCell(s.heading, false, 25),
215
- makeCell(s.synopsis, false, 35),
216
- makeCell(s.actors, false, 15),
217
- makeCell(s.status, false, 10),
218
- makeCell(s.estDuration, false, 5),
219
- ],
220
- })
221
- )
222
- }
223
-
224
- const table = new Table({
225
- width: { size: 100, type: WidthType.PERCENTAGE },
226
- rows,
227
- })
228
-
229
- const doc = new Document({
230
- sections: [
231
- {
232
- properties: {},
233
- children: [
234
- new Paragraph({
235
- children: [
236
- new TextRun({
237
- text: 'ONE-LINER SCHEDULE / SCENE BREAKDOWN',
238
- bold: true,
239
- font: 'Courier Prime',
240
- size: '14pt',
241
- }),
242
- ],
243
- spacing: { after: 288 },
244
- }),
245
- table,
246
- ],
247
- },
248
- ],
249
- })
250
-
251
- return Packer.toBlob(doc)
252
- }
253
-
254
- export async function exportShotListDocx(scenes: WorkflowSceneData[]): Promise<Blob> {
255
- const headerRow = new TableRow({
256
- children: [
257
- makeCell('Scene #', true, 10),
258
- makeCell('Heading', true, 25),
259
- makeCell('Actors', true, 20),
260
- makeCell('Shot Details', true, 45),
261
- ],
262
- })
263
-
264
- const rows = [headerRow]
265
- for (const s of scenes) {
266
- const shotDetails = s.shots.join('\n')
267
- rows.push(
268
- new TableRow({
269
- children: [
270
- makeCell(s.number, false, 10),
271
- makeCell(s.heading, false, 25),
272
- makeCell(s.actors, false, 20),
273
- makeCell(shotDetails || 'No shots specified', false, 45),
274
- ],
275
- })
276
- )
277
- }
278
-
279
- const table = new Table({
280
- width: { size: 100, type: WidthType.PERCENTAGE },
281
- rows,
282
- })
283
-
284
- const doc = new Document({
285
- sections: [
286
- {
287
- properties: {},
288
- children: [
289
- new Paragraph({
290
- children: [
291
- new TextRun({
292
- text: 'SHOT LIST REPORT',
293
- bold: true,
294
- font: 'Courier Prime',
295
- size: '14pt',
296
- }),
297
- ],
298
- spacing: { after: 288 },
299
- }),
300
- table,
301
- ],
302
- },
303
- ],
304
- })
305
-
306
- return Packer.toBlob(doc)
307
- }
308
-
309
- export async function exportCastReportDocx(characters: CharacterStats[]): Promise<Blob> {
310
- const headerRow = new TableRow({
311
- children: [
312
- makeCell('Character', true, 25),
313
- makeCell('Actor Name', true, 25),
314
- makeCell('Occurrences', true, 15),
315
- makeCell('Scenes List', true, 35),
316
- ],
317
- })
318
-
319
- const rows = [headerRow]
320
- for (const c of characters) {
321
- rows.push(
322
- new TableRow({
323
- children: [
324
- makeCell(c.name, false, 25),
325
- makeCell(c.actor || 'TBD', false, 25),
326
- makeCell(String(c.sceneCount), false, 15),
327
- makeCell(c.scenes.join(', '), false, 35),
328
- ],
329
- })
330
- )
331
- }
332
-
333
- const table = new Table({
334
- width: { size: 100, type: WidthType.PERCENTAGE },
335
- rows,
336
- })
337
-
338
- const doc = new Document({
339
- sections: [
340
- {
341
- properties: {},
342
- children: [
343
- new Paragraph({
344
- children: [
345
- new TextRun({
346
- text: 'CHARACTER OCCURRENCE / CAST REPORT',
347
- bold: true,
348
- font: 'Courier Prime',
349
- size: '14pt',
350
- }),
351
- ],
352
- spacing: { after: 288 },
353
- }),
354
- table,
355
- ],
356
- },
357
- ],
358
- })
359
-
360
- return Packer.toBlob(doc)
361
- }
362
-
363
- // ---------------------------------------------------------
364
- // Print Preview Page Generator (HTML)
365
- // ---------------------------------------------------------
366
-
367
- export async function openPrintPreview(scenes: WorkflowSceneData[], title = 'One-Liner Schedule') {
368
- // Open synchronously (inside the click) so popup blockers allow it, then embed fonts.
369
- const win = window.open('', '_blank')
370
- if (!win) return
371
- // Noto Sans carries every ISO 15919 letter and mark, for romanized reports; the new
372
- // window has no stylesheet of its own.
373
- const fontFaceCss = await embedFontFacesFor(['Noto Sans'])
374
-
375
- const rowsHtml = scenes
376
- .map(
377
- s => `
378
- <tr>
379
- <td>${s.number}</td>
380
- <td><strong>${s.heading}</strong></td>
381
- <td>${s.synopsis || '<span class="empty">No synopsis</span>'}</td>
382
- <td>${s.actors || '-'}</td>
383
- <td><span class="status-badge status-${s.status.toLowerCase().replace(/\s+/g, '-') || 'none'}">${s.status || '-'}</span></td>
384
- <td>${s.estDuration || '-'}</td>
385
- </tr>
386
- `
387
- )
388
- .join('')
389
-
390
- win.document.write(`
391
- <!DOCTYPE html>
392
- <html>
393
- <head>
394
- <title>${title}</title>
395
- <style>
396
- ${fontFaceCss}
397
- body {
398
- font-family: 'Noto Sans', 'Courier Prime', sans-serif;
399
- margin: 40px;
400
- color: #333;
401
- }
402
- h1 {
403
- font-family: 'Courier Prime', sans-serif;
404
- text-align: center;
405
- font-size: 20px;
406
- margin-bottom: 30px;
407
- text-transform: uppercase;
408
- letter-spacing: 1px;
409
- }
410
- table {
411
- width: 100%;
412
- border-collapse: collapse;
413
- font-size: 13px;
414
- }
415
- th, td {
416
- border: 1px solid #ccc;
417
- padding: 10px 12px;
418
- text-align: left;
419
- vertical-align: top;
420
- }
421
- th {
422
- background-color: #f7f7f5;
423
- font-weight: 600;
424
- }
425
- .empty {
426
- color: #999;
427
- font-style: italic;
428
- }
429
- .status-badge {
430
- display: inline-block;
431
- padding: 2px 6px;
432
- border-radius: 4px;
433
- font-size: 11px;
434
- font-weight: 600;
435
- background: #f0f0f0;
436
- border: 1px solid #ddd;
437
- }
438
- .status-done, .status-revised { background: #e6f4ea; color: #137333; border-color: #ceead6; }
439
- .status-wip { background: #fef7e0; color: #b06000; border-color: #feebc8; }
440
- .status-draft { background: #fce8e6; color: #c5221f; border-color: #fad2cf; }
441
-
442
- @media print {
443
- body { margin: 20px; }
444
- button { display: none !important; }
445
- th { background-color: #f0f0f0 !important; -webkit-print-color-adjust: exact; }
446
- }
447
-
448
- .print-btn-bar {
449
- text-align: right;
450
- margin-bottom: 20px;
451
- }
452
- .print-btn {
453
- background: #1976d2;
454
- color: white;
455
- border: none;
456
- padding: 8px 16px;
457
- font-size: 13px;
458
- border-radius: 4px;
459
- cursor: pointer;
460
- font-weight: 600;
461
- }
462
- .print-btn:hover { background: #1565c0; }
463
- </style>
464
- </head>
465
- <body>
466
- <div class="print-btn-bar">
467
- <button class="print-btn" onclick="window.print()">Print Schedule</button>
468
- </div>
469
- <h1>${title}</h1>
470
- <table>
471
- <thead>
472
- <tr>
473
- <th style="width: 8%">Scene #</th>
474
- <th style="width: 25%">Heading</th>
475
- <th style="width: 35%">Synopsis</th>
476
- <th style="width: 15%">Actors</th>
477
- <th style="width: 12%">Status</th>
478
- <th style="width: 5%">Duration</th>
479
- </tr>
480
- </thead>
481
- <tbody>
482
- ${rowsHtml}
483
- </tbody>
484
- </table>
485
- </body>
486
- </html>
487
- `)
488
- win.document.close()
489
- }
490
-
491
- /** Print Screenplay Preview in formatted style. `styleOverride` picks a style id for
492
- * this export only (without touching the document); otherwise the document's
493
- * frontmatter `style:` field is used, falling back to the default style. `skipNotes`
494
- * omits both block-level and inline `[[ ]]` notes from the exported document. */
495
- export async function openScreenplayPrintPreview(
496
- ast: DocumentNode,
497
- title = 'Screenplay',
498
- styleOverride?: string,
499
- customStyles: ScreenplayStyleDefinition[] = [],
500
- skipNotes = false,
501
- ) {
502
- const html = await buildScreenplayPrintHtml(ast, title, styleOverride, customStyles, skipNotes)
503
-
504
- // Desktop shell: generate a real vector-text PDF via the platform's browser
505
- // engine (WebView2 PrintToPdf on Windows) instead of the OS print dialog,
506
- // which avoids the rasterized/bloated output some print-to-PDF drivers produce.
507
- const api = window.desktopAPI
508
- if (api?.exportScreenplayToPdf && api?.showSavePdfDialog) {
509
- const defaultName = `${title.replace(/[\\/:*?"<>|]/g, '_')}.pdf`
510
- const path = await api.showSavePdfDialog(defaultName)
511
- if (!path) return
512
- await api.exportScreenplayToPdf(html, path)
513
- return
514
- }
515
-
516
- const win = window.open('', '_blank')
517
- if (!win) return
518
- win.document.write(html)
519
- win.document.close()
520
- }
521
-
522
- async function buildScreenplayPrintHtml(
523
- ast: DocumentNode,
524
- title: string,
525
- styleOverride?: string,
526
- customStyles: ScreenplayStyleDefinition[] = [],
527
- skipNotes = false,
528
- ): Promise<string> {
529
- const frontmatter = ast.frontmatter?.data as Record<string, unknown> | undefined
530
- const defaultLang = (frontmatter?.['lang'] as string) ?? 'en'
531
- const style = resolveStyle(styleOverride ?? (frontmatter?.['style'] as string | undefined), customStyles)
532
- // Noto Sans is the Latin fallback in the print font stack (print-adapter.ts), so embed it
533
- // too: the desktop's hidden print webview cannot load /fonts/ paths on its own.
534
- const embeddedFontFaceCss = await embedFontFacesFor([...new Set([
535
- style.fontFamily,
536
- ...Object.values(style.complexScriptFontOverrides ?? {}),
537
- 'Noto Sans',
538
- ])])
539
-
540
- // Paper the export prints on — frontmatter's `page:`, the same field docx-exporter.ts
541
- // reads. print-paginator.ts needs it in CSS px to size its page boxes.
542
- const pageFormat = (frontmatter?.['page'] as string)?.toLowerCase() === 'a4' ? 'a4' : 'letter'
543
- const pageHeightIn = pageFormat === 'a4' ? 11.69 : 11
544
-
545
- const watermarkValue = typeof frontmatter?.['watermark'] === 'string' ? frontmatter['watermark'].trim() : ''
546
- const watermarkHtml = watermarkValue ? `<div class="print-watermark">${escapeHtml(watermarkValue)}</div>` : ''
547
-
548
- // Render cover page
549
- let coverPageHtml = ''
550
- if (frontmatter && (frontmatter['title'] || frontmatter['author'])) {
551
- const titleText = String(frontmatter['title'] || 'Untitled')
552
- const authorText = String(frontmatter['author'] || '')
553
- const loglineText = String(frontmatter['logline'] || '')
554
- const dateText = String(frontmatter['date'] || '')
555
- const contactText = String(frontmatter['contact'] || '')
556
-
557
- // Both cover layouts share this markup — the two blocks are what 'corners' pushes
558
- // into opposite corners and 'centered' just stacks (see print-adapter.ts). The
559
- // byline differs: 'corners' prints one "by <author>" line, 'centered' keeps the
560
- // classic "by" / author on separate lines.
561
- const corners = (style.coverLayout ?? DEFAULT_COVER_LAYOUT) === 'corners'
562
- const bylineHtml = corners
563
- ? `<div class="print-cover-author">by ${textHtml(authorText)}</div>`
564
- : `<div class="print-cover-by">by</div>
565
- <div class="print-cover-author">${textHtml(authorText)}</div>`
566
- coverPageHtml = `
567
- <div class="print-cover-page print-cover-${corners ? 'corners' : 'centered'}">
568
- <div class="print-cover-top">
569
- <div class="print-cover-title">${textHtml(titleText)}</div>
570
- ${bylineHtml}
571
- ${loglineText ? `<div class="print-cover-logline">${textHtml(loglineText)}</div>` : ''}
572
- </div>
573
- <div class="print-cover-bottom">
574
- ${dateText ? `<div class="print-cover-date">${escapeHtml(dateText)}</div>` : ''}
575
- ${contactText ? `<div class="print-cover-contact">${escapeHtml(contactText)}</div>` : ''}
576
- </div>
577
- </div>
578
- `
579
- }
580
-
581
- // Render screenplay nodes
582
- let bodyHtml = ''
583
-
584
- function renderSpans(spans: any[]): string {
585
- return spans.map(s => {
586
- if (s.type === 'text') return textHtml(s.text)
587
- if (s.type === 'bold') return `<strong>${renderSpans(s.spans)}</strong>`
588
- if (s.type === 'italic') return `<em>${renderSpans(s.spans)}</em>`
589
- if (s.type === 'underline') return `<u>${renderSpans(s.spans)}</u>`
590
- if (s.type === 'note') return skipNotes ? '' : `<span class="print-note">[[ ${textHtml(s.text)} ]]</span>`
591
- return ''
592
- }).join('')
593
- }
594
-
595
- function escapeHtml(text: string): string {
596
- return text
597
- .replace(/&/g, '&amp;')
598
- .replace(/</g, '&lt;')
599
- .replace(/>/g, '&gt;')
600
- .replace(/"/g, '&quot;')
601
- .replace(/'/g, '&#039;')
602
- }
603
-
604
- /** Whether `text` contains a script this style forces italic by default
605
- * (`italicScripts`, e.g. Traditional's Tamil) — mirrors docx-exporter.ts's
606
- * createRuns forced-italic behavior, which the live editor and DOCX export
607
- * already have but the print/PDF HTML never did (no per-span language tagging
608
- * there), so Tamil rendered upright in exported PDFs even under Traditional. */
609
- function forceItalic(text: string): boolean {
610
- if (!style.italicScripts?.length) return false
611
- return style.italicScripts.some(code => SCRIPT_UNICODE_RANGES[code]?.test(text))
612
- }
613
-
614
- /** escapeHtml, plus <i>-wrapping when forceItalic(text) — italic always,
615
- * even inside otherwise-bold/underlined elements like Traditional's scene
616
- * headings and character cues, matching the confirmed docx-exporter.ts behavior. */
617
- function textHtml(text: string): string {
618
- const escaped = escapeHtml(text)
619
- return forceItalic(text) ? `<i>${escaped}</i>` : escaped
620
- }
621
-
622
- function renderContentNode(n: ContentNode): string {
623
- if (n.type === 'action') {
624
- return `<div class="print-action">${renderSpans(n.spans)}</div>`
625
- }
626
- if (n.type === 'character') {
627
- const ext = n.extension ? ` ${n.extension.toUpperCase()}` : ''
628
- const childHtml = n.children.map(c => {
629
- if (c.type === 'parenthetical') {
630
- return `<div class="print-parenthetical">(${textHtml(c.text)})</div>`
631
- } else {
632
- return `<div class="print-dialogue">${renderSpans(c.spans)}</div>`
633
- }
634
- }).join('')
635
-
636
- return `
637
- <div class="print-character-block">
638
- <div class="print-character">${textHtml(n.name.toUpperCase())}${textHtml(ext)}</div>
639
- ${childHtml}
640
- </div>
641
- `
642
- }
643
- if (n.type === 'transition') {
644
- return `<div class="print-transition">${textHtml(n.text.toUpperCase())}</div>`
645
- }
646
- if (n.type === 'centered') {
647
- return `<div class="print-centered">${textHtml(n.text)}</div>`
648
- }
649
- if (n.type === 'lyrics') {
650
- return `<div class="print-lyrics">${renderSpans(n.spans)}</div>`
651
- }
652
- if (n.type === 'note') {
653
- return skipNotes ? '' : `<div class="print-note">${textHtml(n.text)}</div>`
654
- }
655
- if (n.type === 'comment') {
656
- return `<div class="print-comment">&lt;!-- ${textHtml(n.text)} --&gt;</div>`
657
- }
658
- if (n.type === 'page-break') {
659
- return `<div class="print-page-break"></div>`
660
- }
661
- if (n.type === 'dual-dialogue') {
662
- const leftChar = n.left
663
- const leftExt = leftChar.extension ? ` ${leftChar.extension.toUpperCase()}` : ''
664
- const leftChildHtml = leftChar.children.map(c => {
665
- if (c.type === 'parenthetical') {
666
- return `<div class="print-parenthetical">(${textHtml(c.text)})</div>`
667
- } else {
668
- return `<div class="print-dialogue">${renderSpans(c.spans)}</div>`
669
- }
670
- }).join('')
671
-
672
- const rightChar = n.right
673
- const rightExt = rightChar.extension ? ` ${rightChar.extension.toUpperCase()}` : ''
674
- const rightChildHtml = rightChar.children.map(c => {
675
- if (c.type === 'parenthetical') {
676
- return `<div class="print-parenthetical">(${textHtml(c.text)})</div>`
677
- } else {
678
- return `<div class="print-dialogue">${renderSpans(c.spans)}</div>`
679
- }
680
- }).join('')
681
-
682
- return `
683
- <div class="print-dual-dialogue">
684
- <div class="print-dual-column print-left-column">
685
- <div class="print-character">${textHtml(leftChar.name.toUpperCase())}${textHtml(leftExt)}</div>
686
- ${leftChildHtml}
687
- </div>
688
- <div class="print-dual-column print-right-column">
689
- <div class="print-character">${textHtml(rightChar.name.toUpperCase())}${textHtml(rightExt)}</div>
690
- ${rightChildHtml}
691
- </div>
692
- </div>
693
- `
694
- }
695
- return ''
696
- }
697
-
698
- /** Stamps the scene a block belongs to onto its outermost element, which is how
699
- * print-paginator.ts decides whether a page opens mid-scene (-> "8 CONTINUED:") or on
700
- * a fresh one. The label is the scene's own number when it has one, else its position
701
- * in the script — the same thing the heading prints on the right. */
702
- function tagScene(html: string, label: string, isHeading = false): string {
703
- const attrs = ` data-scene="${escapeHtml(label)}"${isHeading ? ` data-scene-start="${escapeHtml(label)}"` : ''}`
704
- return html.replace('<div', `<div${attrs}`)
705
- }
706
-
707
- let sceneOrdinal = 0
708
- for (const node of ast.children) {
709
- if (node.type === 'section') {
710
- bodyHtml += `<div class="print-section print-sect-${node.level}">${textHtml(node.text)}</div>`
711
- for (const sub of node.children) {
712
- bodyHtml += renderContentNode(sub)
713
- }
714
- } else if (node.type === 'scene-heading') {
715
- sceneOrdinal++
716
- const sceneLabel = node.id || String(sceneOrdinal)
717
- bodyHtml += tagScene(`
718
- <div class="print-scene-heading">
719
- <span class="print-scene-title">${textHtml(node.text.toUpperCase())}</span>
720
- ${node.id ? `<span class="print-scene-num">${escapeHtml(node.id)}</span>` : ''}
721
- </div>
722
- `, sceneLabel, true)
723
- for (const sub of node.children) {
724
- bodyHtml += tagScene(renderContentNode(sub), sceneLabel)
725
- }
726
- } else {
727
- bodyHtml += renderContentNode(node)
728
- }
729
- }
730
-
731
- return `
732
- <!DOCTYPE html>
733
- <html>
734
- <head>
735
- <title>${escapeHtml(title)}</title>
736
- <link rel="preconnect" href="https://fonts.googleapis.com">
737
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
738
- <link href="https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&family=Noto+Sans+Devanagari&family=Noto+Sans+Tamil&family=Noto+Sans+Telugu&family=Noto+Sans+Kannada&family=Noto+Sans+Malayalam&family=Noto+Sans+Bengali&family=Noto+Sans+Gujarati&family=Noto+Sans+Gurmukhi&family=Noto+Sans+Oriya&family=Noto+Serif:ital,wght@0,400;0,700;1,400&family=Noto+Serif+Devanagari&family=Noto+Serif+Tamil:ital@0;1&family=Noto+Serif+Telugu&family=Noto+Serif+Kannada&family=Noto+Serif+Malayalam&family=Noto+Serif+Bengali&family=Noto+Serif+Gujarati&family=Noto+Serif+Gurmukhi&family=Noto+Serif+Oriya&display=swap" rel="stylesheet">
739
- <style>
740
- /* Statically-instanced (non-variable) copies of this export's own fonts, embedded
741
- as data: URIs — see embed-fonts.ts for why: Chromium's print/PDF-to-file font
742
- subsetter has known bugs mishandling GPOS/GSUB (glyph positioning/substitution —
743
- required for correct Indic conjunct/vowel-sign shaping) on *variable* fonts
744
- specifically, even though the same font shapes perfectly on-screen. The Google
745
- Fonts <link> above only serves variable Indic-script families, so without this,
746
- a style that needs one (e.g. Traditional's Noto Serif Tamil) renders correctly in
747
- the on-screen preview but comes out garbled the moment it's actually printed/
748
- exported to PDF. Declared after the <link> so these take cascade precedence for
749
- the same family/weight/style. */
750
- ${embeddedFontFaceCss}
751
- </style>
752
- <style>
753
- /* Layout-neutral part only — the two cover layouts (.print-cover-centered /
754
- .print-cover-corners) come from the style, so they live in print-adapter.ts. */
755
- .print-cover-page {
756
- page-break-after: always;
757
- box-sizing: border-box;
758
- }
759
- ${styleToPrintCss(style, pageFormat)}
760
-
761
- /* Interface / buttons */
762
- .print-btn-bar {
763
- text-align: right;
764
- margin-bottom: 20px;
765
- }
766
- .print-btn {
767
- background: #0a5c6a;
768
- color: white;
769
- border: none;
770
- padding: 8px 16px;
771
- font-size: 13px;
772
- border-radius: 4px;
773
- cursor: pointer;
774
- font-weight: 600;
775
- }
776
- .print-btn:hover {
777
- background: #084954;
778
- }
779
-
780
- /* Watermark, from frontmatter's watermark: field. position: fixed repeats
781
- it on every printed page in Chromium's print/print-to-pdf engine (including
782
- WebView2, used for the native Windows PDF export) -- the same trick the live
783
- editor's .cs-watermark uses to stay visible while scrolling. */
784
- .print-watermark {
785
- position: fixed;
786
- top: 50%;
787
- left: 50%;
788
- transform: translate(-50%, -50%) rotate(-45deg);
789
- font-size: 60pt;
790
- font-weight: 700;
791
- color: rgba(0, 0, 0, 0.08);
792
- white-space: nowrap;
793
- pointer-events: none;
794
- z-index: -1;
795
- }
796
-
797
- @media print {
798
- .print-btn-bar { display: none !important; }
799
- body { padding: 0; }
800
- }
801
- </style>
802
- </head>
803
- <body>
804
- <div class="print-btn-bar">
805
- <button class="print-btn" onclick="window.print()">Export to PDF / Print</button>
806
- </div>
807
- ${watermarkHtml}
808
- ${coverPageHtml}
809
- <div id="print-pages"></div>
810
- <div id="print-flow">${bodyHtml}</div>
811
- ${printPaginatorScript({
812
- contentHeightPx: Math.round((pageHeightIn - style.page.marginTopIn - style.page.marginBottomIn) * CSS_PX_PER_IN),
813
- pageNumberPrefix: 'p',
814
- continuedSuffix: 'CONTINUED:',
815
- })}
816
- </body>
817
- </html>
818
- `
819
- }
820
-