@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,313 +0,0 @@
1
- import JSZip from 'jszip'
2
-
3
- const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
4
-
5
- /** Paragraph styles produced by docx-exporter.ts that mark actual screenplay body content
6
- * (as opposed to the free-form title-page paragraphs, which carry no style, or CineTitle /
7
- * CineAuthor / CineLogline, which belong to the title page and are not reimported here). */
8
- const BODY_STYLES = new Set([
9
- 'CineSceneHeading', 'CineSection', 'CineAction', 'CineCharacter',
10
- 'CineParenthetical', 'CineDialogue', 'CineTransition', 'CineCentered',
11
- 'CineLyrics', 'CineNote', 'CineComment',
12
- ])
13
-
14
- interface RunFlags {
15
- bold: boolean
16
- italic: boolean
17
- underline: boolean
18
- }
19
-
20
- interface ParsedParagraph {
21
- style: string | null
22
- text: string
23
- runs: (RunFlags & { text: string })[]
24
- isPageBreak: boolean
25
- }
26
-
27
- interface ParsedTable {
28
- cells: ParsedParagraph[][]
29
- }
30
-
31
- type BodyItem = { kind: 'para'; para: ParsedParagraph } | { kind: 'table'; table: ParsedTable }
32
-
33
- export interface DocxImportResult {
34
- /** Reconstructed Sutra body text (no frontmatter), or null if nothing importable was found. */
35
- bodyText: string | null
36
- warnings: string[]
37
- }
38
-
39
- function attrVal(el: Element, tag: string): string | null {
40
- const child = el.getElementsByTagNameNS(W_NS, tag)[0]
41
- if (!child) return null
42
- const val = child.getAttributeNS(W_NS, 'val')
43
- return val
44
- }
45
-
46
- function flagOn(el: Element, tag: string): boolean {
47
- const child = el.getElementsByTagNameNS(W_NS, tag)[0]
48
- if (!child) return false
49
- const val = child.getAttributeNS(W_NS, 'val')
50
- if (val === null) return true
51
- return !['false', '0', 'off', 'none'].includes(val.toLowerCase())
52
- }
53
-
54
- function parseParagraph(p: Element): ParsedParagraph {
55
- let style: string | null = null
56
- const runs: (RunFlags & { text: string })[] = []
57
- let isPageBreak = false
58
-
59
- for (const child of Array.from(p.children)) {
60
- if (child.localName === 'pPr') {
61
- style = attrVal(child, 'pStyle')
62
- continue
63
- }
64
- if (child.localName === 'r') {
65
- const rPr = child.getElementsByTagNameNS(W_NS, 'rPr')[0]
66
- const bold = !!rPr && flagOn(rPr, 'b')
67
- const italic = !!rPr && flagOn(rPr, 'i')
68
- const underline = !!rPr && flagOn(rPr, 'u')
69
- let text = ''
70
- for (const rc of Array.from(child.children)) {
71
- if (rc.localName === 't') text += rc.textContent ?? ''
72
- // Word normalizes a literal tab character inside <w:t> (what docx.js emits) into a
73
- // <w:tab/> element when it resaves the file — recognize both forms.
74
- else if (rc.localName === 'tab') text += '\t'
75
- else if (rc.localName === 'br') isPageBreak = true
76
- }
77
- if (text) runs.push({ text, bold, italic, underline })
78
- }
79
- }
80
-
81
- return { style, text: runs.map(r => r.text).join(''), runs, isPageBreak }
82
- }
83
-
84
- function parseTable(tbl: Element): ParsedTable {
85
- const tr = tbl.getElementsByTagNameNS(W_NS, 'tr')[0]
86
- const cells: ParsedParagraph[][] = []
87
- if (tr) {
88
- for (const tc of Array.from(tr.children)) {
89
- if (tc.localName !== 'tc') continue
90
- const paras = Array.from(tc.children)
91
- .filter(c => c.localName === 'p')
92
- .map(p => parseParagraph(p))
93
- cells.push(paras)
94
- }
95
- }
96
- return { cells }
97
- }
98
-
99
- /** Rebuilds bold/italic/underline markup from formatting runs, matching the parser's inline syntax.
100
- * Types that don't support inline spans in Sutra source (headings, character cues, notes, etc.)
101
- * should pass all three `allow` flags false so forced style formatting (e.g. bold character names)
102
- * isn't reinterpreted as user markup. */
103
- function renderInline(
104
- runs: (RunFlags & { text: string })[],
105
- opts: { allowBold: boolean; allowItalic: boolean; allowUnderline: boolean }
106
- ): string {
107
- let out = ''
108
- for (const r of runs) {
109
- const bold = opts.allowBold && r.bold
110
- const italic = opts.allowItalic && r.italic
111
- const underline = opts.allowUnderline && r.underline
112
- let text = r.text
113
- if (!text) continue
114
- if (underline) text = `_${text}_`
115
- if (italic) text = `*${text}*`
116
- if (bold) text = `**${text}**`
117
- out += text
118
- }
119
- return out
120
- }
121
-
122
- function plainText(para: ParsedParagraph): string {
123
- return para.text
124
- }
125
-
126
- function splitNameExtension(text: string): { name: string; extension: string | null } {
127
- const m = text.trim().match(/^(.*)\s(\([^()]*\))$/)
128
- if (m) return { name: m[1]!.trim(), extension: m[2]! }
129
- return { name: text.trim(), extension: null }
130
- }
131
-
132
- function renderCharacterLine(text: string, isDual: boolean): string {
133
- const { name, extension } = splitNameExtension(text)
134
- return `@${name}${extension ? ` ${extension}` : ''}${isDual ? ' ^' : ''}`
135
- }
136
-
137
- function renderSceneHeading(para: ParsedParagraph): string {
138
- const text = plainText(para)
139
- const tabIdx = text.indexOf('\t')
140
- if (tabIdx === -1) return `## ${text.trim()}`
141
- const heading = text.slice(0, tabIdx).trim()
142
- const id = text.slice(tabIdx + 1).trim()
143
- return id ? `## ${heading} {#${id}}` : `## ${heading}`
144
- }
145
-
146
- class BlockBuilder {
147
- private blocks: string[] = []
148
- private charLines: string[] | null = null
149
- unstyledCount = 0
150
-
151
- private flushChar() {
152
- if (this.charLines) {
153
- this.blocks.push(this.charLines.join('\n'))
154
- this.charLines = null
155
- }
156
- }
157
-
158
- push(block: string) {
159
- this.flushChar()
160
- this.blocks.push(block)
161
- }
162
-
163
- startCharacter(line: string) {
164
- this.flushChar()
165
- this.charLines = [line]
166
- }
167
-
168
- appendToCharacter(line: string): boolean {
169
- if (!this.charLines) return false
170
- this.charLines.push(line)
171
- return true
172
- }
173
-
174
- finish(): string {
175
- this.flushChar()
176
- return this.blocks.join('\n\n')
177
- }
178
- }
179
-
180
- function addParagraph(para: ParsedParagraph, builder: BlockBuilder) {
181
- if (para.isPageBreak) {
182
- builder.push('===')
183
- return
184
- }
185
- const text = plainText(para).trim()
186
-
187
- switch (para.style) {
188
- case 'CineSceneHeading':
189
- builder.push(renderSceneHeading(para))
190
- return
191
- case 'CineSection':
192
- builder.push(`# ${text}`)
193
- return
194
- case 'CineAction': {
195
- const rendered = renderInline(para.runs, { allowBold: true, allowItalic: true, allowUnderline: true })
196
- if (rendered.trim()) builder.push(rendered)
197
- return
198
- }
199
- case 'CineCharacter':
200
- builder.startCharacter(renderCharacterLine(text, false))
201
- return
202
- case 'CineParenthetical':
203
- if (!builder.appendToCharacter(text)) builder.push(text)
204
- return
205
- case 'CineDialogue': {
206
- const rendered = renderInline(para.runs, { allowBold: true, allowItalic: true, allowUnderline: true })
207
- if (!builder.appendToCharacter(rendered)) builder.push(rendered)
208
- return
209
- }
210
- case 'CineTransition':
211
- builder.push(`>> ${text}`)
212
- return
213
- case 'CineCentered':
214
- builder.push(`>> ${text} <<`)
215
- return
216
- case 'CineLyrics': {
217
- const rendered = renderInline(para.runs, { allowBold: true, allowItalic: false, allowUnderline: true })
218
- builder.push(`~ ${rendered}`)
219
- return
220
- }
221
- case 'CineNote':
222
- builder.push(text)
223
- return
224
- case 'CineComment':
225
- builder.push(text)
226
- return
227
- default: {
228
- if (!text) return
229
- builder.unstyledCount++
230
- const rendered = renderInline(para.runs, { allowBold: true, allowItalic: true, allowUnderline: true })
231
- builder.push(rendered)
232
- }
233
- }
234
- }
235
-
236
- function isBodyStart(item: BodyItem): boolean {
237
- if (item.kind === 'table') return true
238
- return item.para.style !== null && BODY_STYLES.has(item.para.style)
239
- }
240
-
241
- /** Parses a .docx file (as produced by exportToDocx, possibly re-saved from Word with
242
- * corrections and/or font changes) back into Sutra body text. The title page, if any,
243
- * is not reimported — only content carrying the CineXxx paragraph styles the exporter
244
- * applies to the screenplay body is recognized. */
245
- export async function importDocx(buffer: ArrayBuffer): Promise<DocxImportResult> {
246
- const warnings: string[] = []
247
- let zip: JSZip
248
- try {
249
- zip = await JSZip.loadAsync(buffer)
250
- } catch {
251
- throw new Error('This file is not a valid .docx (Word) document.')
252
- }
253
- const docXml = zip.file('word/document.xml')
254
- if (!docXml) throw new Error('This file is not a valid .docx (Word) document.')
255
- const xmlText = await docXml.async('text')
256
-
257
- const parser = new DOMParser()
258
- const xmlDoc = parser.parseFromString(xmlText, 'application/xml')
259
- if (xmlDoc.getElementsByTagName('parsererror').length > 0) {
260
- throw new Error('Could not read the Word document — its XML appears corrupted.')
261
- }
262
- const body = xmlDoc.getElementsByTagNameNS(W_NS, 'body')[0]
263
- if (!body) throw new Error('This .docx file has no document body.')
264
-
265
- const items: BodyItem[] = []
266
- for (const child of Array.from(body.children)) {
267
- if (child.localName === 'p') items.push({ kind: 'para', para: parseParagraph(child) })
268
- else if (child.localName === 'tbl') items.push({ kind: 'table', table: parseTable(child) })
269
- }
270
-
271
- const startIdx = items.findIndex(isBodyStart)
272
- if (startIdx === -1) {
273
- warnings.push('No Sutrata screenplay content was found in this document — nothing was imported.')
274
- return { bodyText: null, warnings }
275
- }
276
-
277
- const builder = new BlockBuilder()
278
- let skippedTables = 0
279
-
280
- for (let i = startIdx; i < items.length; i++) {
281
- const item = items[i]!
282
- if (item.kind === 'para') {
283
- addParagraph(item.para, builder)
284
- continue
285
- }
286
- // dual-dialogue table: exactly two cells, each a character block
287
- if (item.table.cells.length !== 2) {
288
- skippedTables++
289
- continue
290
- }
291
- const [left, right] = item.table.cells as [ParsedParagraph[], ParsedParagraph[]]
292
- for (const p of left) addParagraph(p, builder)
293
- const rightFirst = right[0]
294
- if (rightFirst && rightFirst.style === 'CineCharacter') {
295
- builder.startCharacter(renderCharacterLine(plainText(rightFirst).trim(), true))
296
- for (const p of right.slice(1)) addParagraph(p, builder)
297
- } else {
298
- for (const p of right) addParagraph(p, builder)
299
- }
300
- }
301
-
302
- const bodyText = builder.finish()
303
-
304
- if (builder.unstyledCount > 0) {
305
- warnings.push(`${builder.unstyledCount} paragraph(s) had no recognized Sutrata style and were imported as action lines.`)
306
- }
307
- if (skippedTables > 0) {
308
- warnings.push(`${skippedTables} table(s) in the document were not recognized as dual dialogue and were skipped.`)
309
- }
310
- warnings.push('The title page was not imported — edit it from the Title Page panel if it also needs corrections.')
311
-
312
- return { bodyText: bodyText || null, warnings }
313
- }
@@ -1,301 +0,0 @@
1
- /** Per-page headers ("<scene> CONTINUED:" on the left, "p<n>" on the right) need to know
2
- * where the page breaks actually fall, and a browser tells no one that: Chromium supports
3
- * neither CSS Paged Media margin boxes (@top-left/@top-right) nor running elements, and
4
- * exposes no JS hook for its own break positions. So the print document paginates itself:
5
- * the script below runs inside the print window (or the hidden WebView2 window the Windows
6
- * desktop export prints from) and distributes the rendered blocks into fixed-height
7
- * .print-page boxes it can then put a real header on.
8
- *
9
- * It runs in that window, not here — `printPaginatorScript` stringifies the function into
10
- * the exported HTML — so `paginatePrintDocument` must stay self-contained: no imports, no
11
- * module-scope references, nothing a bundler could rename out from under it. */
12
-
13
- export interface PrintPaginationOptions {
14
- /** Printable height of one page in CSS px (paper height minus the style's top/bottom
15
- * margins) — the browser applies those margins via @page, so a box of exactly this
16
- * height fills one sheet. */
17
- contentHeightPx: number
18
- /** Right-hand header cell: this prefix plus the page number (e.g. "p" -> "p12"). */
19
- pageNumberPrefix: string
20
- /** Left-hand header cell on a page that opens mid-scene: the scene number, then this
21
- * (e.g. "8 CONTINUED:"). */
22
- continuedSuffix: string
23
- }
24
-
25
- export function paginatePrintDocument(opts: PrintPaginationOptions): void {
26
- const flow = document.getElementById('print-flow')
27
- const pagesRoot = document.getElementById('print-pages')
28
- if (!flow || !pagesRoot) return
29
- const root: HTMLElement = pagesRoot
30
-
31
- const blocks: HTMLElement[] = []
32
- for (let i = 0; i < flow.children.length; i++) blocks.push(flow.children[i] as HTMLElement)
33
- if (blocks.length === 0) return
34
-
35
- // Elements that may be broken across a page boundary mid-paragraph. Everything else
36
- // moves to the next page whole (a cue, a heading and a note are all short enough that
37
- // splitting one is always the wrong answer).
38
- const SPLITTABLE = /\bprint-(action|dialogue|lyrics)\b/
39
- // ...and elements that must not be the last thing on a page — the same rule
40
- // styles/pagination.ts states for DOCX/CSS, re-stated here because inside a
41
- // self-paginated flow the browser's own break-after: avoid never comes into play.
42
- // (?![\w-] so this doesn't also catch the print-character-block wrapper — a *finished*
43
- // speech is free to end a page; it's the cue inside it that must not.)
44
- const KEEP_WITH_NEXT = /\bprint-(scene-heading|character|parenthetical|section)(?![\w-])/
45
- // A cue + its dialogue: not splittable as text, but its children can be distributed.
46
- const CONTAINER = /\bprint-character-block\b/
47
- const MIN_LINES = 2
48
- // Slack against sub-pixel rounding: a box that measures a hair over the page height
49
- // would spill a stray sliver onto the next sheet.
50
- const SLACK_PX = 4
51
-
52
- type Page = { el: HTMLElement; body: HTMLElement; continued: HTMLElement; number: HTMLElement }
53
- const pages: Page[] = []
54
- function newPage(): Page {
55
- const el = document.createElement('section')
56
- el.className = 'print-page'
57
- el.style.height = opts.contentHeightPx + 'px'
58
- const header = document.createElement('div')
59
- header.className = 'print-page-header'
60
- const continued = document.createElement('span')
61
- continued.className = 'print-page-continued'
62
- const number = document.createElement('span')
63
- number.className = 'print-page-number'
64
- header.appendChild(continued)
65
- header.appendChild(number)
66
- const body = document.createElement('div')
67
- body.className = 'print-page-body'
68
- el.appendChild(header)
69
- el.appendChild(body)
70
- root.appendChild(el)
71
- const page = { el, body, continued, number }
72
- pages.push(page)
73
- // Numbered as soon as the page exists, not in the header pass at the end: an empty
74
- // header measures as a bare margin, and every block would then be placed against a
75
- // page height ~one line too generous.
76
- number.textContent = opts.pageNumberPrefix + String(pages.length)
77
- return page
78
- }
79
-
80
- let page = newPage()
81
- /** How much of the page box is left for body content, in the body's own coordinates —
82
- * i.e. page height less everything above the body (the header and its margin). */
83
- const limit = () =>
84
- opts.contentHeightPx
85
- - (page.body.getBoundingClientRect().top - page.el.getBoundingClientRect().top)
86
- - SLACK_PX
87
-
88
- function lineCount(el: HTMLElement): number {
89
- const range = document.createRange()
90
- range.selectNodeContents(el)
91
- return range.getClientRects().length
92
- }
93
-
94
- /** Every position a line could break at, as (text node, offset) pairs. */
95
- function wordBoundaries(el: HTMLElement): { node: Text; offset: number }[] {
96
- const spots: { node: Text; offset: number }[] = []
97
- const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
98
- let node = walker.nextNode() as Text | null
99
- while (node) {
100
- const text = node.data
101
- for (let i = 1; i < text.length; i++) {
102
- if (/\s/.test(text[i - 1]!) && !/\s/.test(text[i]!)) spots.push({ node, offset: i })
103
- }
104
- node = walker.nextNode() as Text | null
105
- }
106
- return spots
107
- }
108
-
109
- /** Cut `el` so that what stays fits in `maxHeight`, returning the remainder as a new
110
- * element of the same kind (or null when there is no split leaving MIN_LINES on both
111
- * sides — then the whole block moves to the next page instead). */
112
- function split(el: HTMLElement, maxHeight: number): HTMLElement | null {
113
- const range = document.createRange()
114
- // Nothing to split against without a layout engine to measure line boxes with
115
- // (a headless DOM, say) — blocks then move whole rather than being cut blind.
116
- if (typeof range.getClientRects !== 'function') return null
117
- const spots = wordBoundaries(el)
118
- if (spots.length === 0) return null
119
- const top = el.getBoundingClientRect().top
120
- const fits = (spot: { node: Text; offset: number }) => {
121
- range.setStartBefore(el)
122
- range.setEnd(spot.node, spot.offset)
123
- const rects = range.getClientRects()
124
- const last = rects[rects.length - 1]
125
- return last ? last.bottom - top <= maxHeight : true
126
- }
127
- let lo = 0
128
- let hi = spots.length - 1
129
- let best = -1
130
- while (lo <= hi) {
131
- const mid = (lo + hi) >> 1
132
- if (fits(spots[mid]!)) {
133
- best = mid
134
- lo = mid + 1
135
- } else {
136
- hi = mid - 1
137
- }
138
- }
139
- if (best < 0) return null
140
-
141
- const cut = spots[best]!
142
- const rest = el.cloneNode(false) as HTMLElement
143
- const tail = document.createRange()
144
- tail.setStart(cut.node, cut.offset)
145
- tail.setEndAfter(el.lastChild!)
146
- rest.appendChild(tail.extractContents())
147
- page.body.appendChild(rest)
148
- const ok = lineCount(el) >= MIN_LINES && lineCount(rest) >= MIN_LINES
149
- page.body.removeChild(rest)
150
- if (!ok) {
151
- // Put it back exactly as it was rather than leaving a half-cut paragraph behind.
152
- while (rest.firstChild) el.appendChild(rest.firstChild)
153
- el.normalize()
154
- return null
155
- }
156
- return rest
157
- }
158
-
159
- /** A character block is a cue plus its parentheticals and dialogue lines. When it
160
- * straddles a break, the split runs *between* those children (and, where the straddling
161
- * line is dialogue, inside it) so a long speech doesn't jump to the next page whole and
162
- * leave a hole behind. The cue never ends up alone: a continuation is only produced
163
- * when the cue keeps at least part of the first speech with it. */
164
- function splitContainer(el: HTMLElement, maxHeight: number): HTMLElement | null {
165
- const children: HTMLElement[] = []
166
- for (let i = 0; i < el.children.length; i++) children.push(el.children[i] as HTMLElement)
167
- if (children.length < 2) return null
168
- const top = el.getBoundingClientRect().top
169
- let cut = -1
170
- for (let i = 0; i < children.length; i++) {
171
- if (children[i]!.getBoundingClientRect().bottom - top > maxHeight) { cut = i; break }
172
- }
173
- if (cut <= 0) return null // the cue itself doesn't fit: the whole block moves down
174
-
175
- const rest = el.cloneNode(false) as HTMLElement
176
- const crossing = children[cut]!
177
- let from = cut
178
- if (SPLITTABLE.test(crossing.className)) {
179
- const tail = split(crossing, maxHeight - (crossing.getBoundingClientRect().top - top))
180
- if (tail) {
181
- rest.appendChild(tail)
182
- from = cut + 1
183
- }
184
- }
185
- if (from === cut && cut < 2) return null // would strand the cue on its own
186
- for (let i = from; i < children.length; i++) rest.appendChild(children[i]!)
187
- return rest.children.length > 0 ? rest : null
188
- }
189
-
190
- /** Break `block` so what's left fits in `room`, returning the continuation (or null when
191
- * it can't be broken and has to move down whole). */
192
- function breakBlock(block: HTMLElement, room: number): HTMLElement | null {
193
- if (SPLITTABLE.test(block.className)) return split(block, room)
194
- if (CONTAINER.test(block.className)) return splitContainer(block, room)
195
- return null
196
- }
197
-
198
- /** The run of keep-with-next blocks immediately before the break, which have to travel
199
- * to the next page with the block they introduce. */
200
- function trailingKeepWithNext(): HTMLElement[] {
201
- const moved: HTMLElement[] = []
202
- let last = page.body.lastElementChild as HTMLElement | null
203
- while (last && KEEP_WITH_NEXT.test(last.className)) {
204
- moved.unshift(last)
205
- last = last.previousElementSibling as HTMLElement | null
206
- }
207
- // Never empty a page for the sake of the rule — that would loop forever.
208
- return moved.length < page.body.children.length ? moved : []
209
- }
210
-
211
- let pending: HTMLElement | null = null
212
- for (let i = 0; i < blocks.length; i++) {
213
- const block: HTMLElement = pending ?? blocks[i]!
214
- pending = null
215
-
216
- // An explicit Sutra page break (===) just starts the next page.
217
- if (block.className.indexOf('print-page-break') >= 0) {
218
- if (page.body.children.length > 0) page = newPage()
219
- continue
220
- }
221
-
222
- page.body.appendChild(block)
223
- if (page.body.scrollHeight <= limit()) continue
224
-
225
- // Doesn't fit. Break the block itself where that's allowed...
226
- const rest = breakBlock(block, limit() - block.offsetTop)
227
- if (rest) {
228
- page = newPage()
229
- pending = rest // re-measured next time round: a long speech can span three pages
230
- i--
231
- continue
232
- }
233
-
234
- // ...otherwise the whole block moves down, dragging any cue/heading introducing it.
235
- page.body.removeChild(block)
236
- const travelling = trailingKeepWithNext()
237
- if (page.body.children.length === travelling.length) {
238
- // Nothing would be left behind: this block is simply taller than a page, so let
239
- // it sit here and overflow rather than bounce between empty pages forever.
240
- page.body.appendChild(block)
241
- continue
242
- }
243
- page = newPage()
244
- for (let k = 0; k < travelling.length; k++) page.body.appendChild(travelling[k]!)
245
- page.body.appendChild(block)
246
- if (page.body.scrollHeight > limit()) {
247
- const tail = breakBlock(block, limit() - block.offsetTop)
248
- if (tail) {
249
- page = newPage()
250
- pending = tail
251
- i--
252
- }
253
- }
254
- }
255
-
256
- // Headers, once every block has landed: which page a scene starts on is only knowable
257
- // after the fact (a heading can be pushed to the next page by the keep-with-next rule).
258
- const sceneFirstPage: Record<string, number> = {}
259
- for (let i = 0; i < pages.length; i++) {
260
- const body = pages[i]!.body
261
- for (let k = 0; k < body.children.length; k++) {
262
- const scene = body.children[k]!.getAttribute('data-scene-start')
263
- if (scene !== null && sceneFirstPage[scene] === undefined) sceneFirstPage[scene] = i
264
- }
265
- }
266
- for (let i = 0; i < pages.length; i++) {
267
- const p = pages[i]!
268
- const first = p.body.firstElementChild
269
- const scene = first ? first.getAttribute('data-scene') : null
270
- const startsHere = first !== null && first.getAttribute('data-scene-start') !== null
271
- if (scene !== null && !startsHere && sceneFirstPage[scene] !== undefined && sceneFirstPage[scene]! < i) {
272
- p.continued.textContent = scene + ' ' + opts.continuedSuffix
273
- }
274
- }
275
-
276
- flow.parentNode?.removeChild(flow)
277
- }
278
-
279
- /** The `<script>` that runs `paginatePrintDocument` in the exported document, once web
280
- * fonts have settled — measuring before that would paginate against fallback metrics
281
- * (and Indic text reflows a lot between the two). Any failure leaves the un-paginated
282
- * flow exactly as it was, so the export degrades to the browser's own page breaks
283
- * (which styles/pagination.ts's CSS rules still govern) instead of producing nothing. */
284
- export function printPaginatorScript(opts: PrintPaginationOptions): string {
285
- return `<script>
286
- (function () {
287
- var run = function () {
288
- try {
289
- (${paginatePrintDocument.toString()})(${JSON.stringify(opts)});
290
- } catch (e) {
291
- console.error('Sutrata: pagination failed, falling back to browser page breaks', e);
292
- }
293
- };
294
- if (document.fonts && document.fonts.ready) {
295
- document.fonts.ready.then(run, run);
296
- } else {
297
- run();
298
- }
299
- })();
300
- </script>`
301
- }
@@ -1,73 +0,0 @@
1
- export type TemplateType = 'feature' | 'tv-pilot' | 'short' | 'web-series' | 'stage-play'
2
-
3
- const BASE_FEATURE = `---
4
- title: Untitled Feature
5
- author:
6
- page: A4
7
- ---
8
-
9
- # ACT ONE
10
-
11
- ## INT. LOCATION - DAY {#sc1}
12
- =
13
-
14
- `
15
-
16
- const BASE_TV_PILOT = `---
17
- title: Untitled Pilot
18
- author:
19
- page: A4
20
- series:
21
- episode: 1
22
- ---
23
-
24
- ## COLD OPEN
25
-
26
- ## INT. LOCATION - DAY {#sc1}
27
- =
28
-
29
- `
30
-
31
- const BASE_SHORT = `---
32
- title: Untitled Short
33
- author:
34
- page: A4
35
- ---
36
-
37
- ## INT. LOCATION - DAY {#sc1}
38
- =
39
-
40
- `
41
-
42
- const BASE_WEB_SERIES = `---
43
- title: Untitled Web Series
44
- author:
45
- page: A4
46
- series:
47
- episode: 1
48
- ---
49
-
50
- ## INT. LOCATION - DAY {#sc1}
51
- =
52
-
53
- `
54
-
55
- const BASE_STAGE_PLAY = `---
56
- title: Untitled Play
57
- author:
58
- page: A4
59
- ---
60
-
61
- # ACT ONE
62
-
63
- ## Scene 1 {#sc1}
64
-
65
- `
66
-
67
- export const TEMPLATES: Record<TemplateType, string> = {
68
- 'feature': BASE_FEATURE,
69
- 'tv-pilot': BASE_TV_PILOT,
70
- 'short': BASE_SHORT,
71
- 'web-series': BASE_WEB_SERIES,
72
- 'stage-play': BASE_STAGE_PLAY,
73
- }