@bendyline/squisq 1.4.1 → 1.5.1

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 (254) hide show
  1. package/README.md +14 -1
  2. package/dist/{ContentContainer-DNx460y8.d.ts → ContentContainer-BXUlIf18.d.ts} +6 -2
  3. package/dist/{Doc-BeSeH2iX.d.ts → Doc-BBVGq1_9.d.ts} +922 -273
  4. package/dist/{ImageEditDoc-BTIvw0oq.d.ts → ImageEditDoc-FuyGtt6o.d.ts} +11 -4
  5. package/dist/{chunk-ZTX4PFFT.js → chunk-2OIBZYKP.js} +75 -2
  6. package/dist/chunk-2OIBZYKP.js.map +1 -0
  7. package/dist/chunk-2UQOHYE2.js +45 -0
  8. package/dist/chunk-2UQOHYE2.js.map +1 -0
  9. package/dist/chunk-6ATE2PSM.js +344 -0
  10. package/dist/chunk-6ATE2PSM.js.map +1 -0
  11. package/dist/{chunk-57CVQCFX.js → chunk-A4O7GIWE.js} +74 -23
  12. package/dist/chunk-A4O7GIWE.js.map +1 -0
  13. package/dist/{chunk-5WFRKURX.js → chunk-DLXZMS5K.js} +4 -2
  14. package/dist/chunk-DLXZMS5K.js.map +1 -0
  15. package/dist/chunk-ET53IIEP.js +1 -0
  16. package/dist/chunk-ET53IIEP.js.map +1 -0
  17. package/dist/chunk-HWVFJAAH.js +362 -0
  18. package/dist/chunk-HWVFJAAH.js.map +1 -0
  19. package/dist/{chunk-2TMAWB4Y.js → chunk-IMSCRLLV.js} +1 -1
  20. package/dist/chunk-IMSCRLLV.js.map +1 -0
  21. package/dist/{chunk-B4EEON3N.js → chunk-KKNUBQ6Y.js} +536 -103
  22. package/dist/chunk-KKNUBQ6Y.js.map +1 -0
  23. package/dist/chunk-LDQ2HJIX.js +596 -0
  24. package/dist/chunk-LDQ2HJIX.js.map +1 -0
  25. package/dist/chunk-LH7I6SH7.js +7208 -0
  26. package/dist/chunk-LH7I6SH7.js.map +1 -0
  27. package/dist/chunk-PGGGKYWG.js +1 -0
  28. package/dist/chunk-PGGGKYWG.js.map +1 -0
  29. package/dist/{chunk-7UDSRZKG.js → chunk-Q3ROPT5H.js} +35 -14
  30. package/dist/chunk-Q3ROPT5H.js.map +1 -0
  31. package/dist/chunk-QE7TWGVT.js +178 -0
  32. package/dist/chunk-QE7TWGVT.js.map +1 -0
  33. package/dist/chunk-RUDYOTA6.js +345 -0
  34. package/dist/chunk-RUDYOTA6.js.map +1 -0
  35. package/dist/{chunk-K32VJONL.js → chunk-T5UK6YOB.js} +41 -2
  36. package/dist/chunk-T5UK6YOB.js.map +1 -0
  37. package/dist/{chunk-3K5OG6KI.js → chunk-TEEEILMP.js} +412 -186
  38. package/dist/chunk-TEEEILMP.js.map +1 -0
  39. package/dist/{chunk-FR2RBTKO.js → chunk-TQWLI6S2.js} +93 -27
  40. package/dist/chunk-TQWLI6S2.js.map +1 -0
  41. package/dist/{chunk-WMOI7FN3.js → chunk-UY7KGQ5R.js} +12 -2
  42. package/dist/{chunk-WMOI7FN3.js.map → chunk-UY7KGQ5R.js.map} +1 -1
  43. package/dist/{chunk-3E5F2XMR.js → chunk-VWUFZ6ZG.js} +29 -2
  44. package/dist/chunk-VWUFZ6ZG.js.map +1 -0
  45. package/dist/chunk-ZLQKNOZQ.js +1 -0
  46. package/dist/chunk-ZLQKNOZQ.js.map +1 -0
  47. package/dist/doc/index.d.ts +5 -5
  48. package/dist/doc/index.js +135 -8
  49. package/dist/generate/index.d.ts +3 -74
  50. package/dist/generate/index.js +2 -7
  51. package/dist/icons/index.d.ts +3 -26
  52. package/dist/icons/index.js +13 -0
  53. package/dist/icons/inlineIconMarker.d.ts +1 -0
  54. package/dist/icons/inlineIconMarker.js +15 -0
  55. package/dist/icons/inlineIconMarker.js.map +1 -0
  56. package/dist/imageEdit/index.d.ts +12 -6
  57. package/dist/imageEdit/index.js +4 -1
  58. package/dist/index.d.ts +14 -11
  59. package/dist/index.js +265 -37
  60. package/dist/inlineIconMarker-CiE8Hlvz.d.ts +59 -0
  61. package/dist/jsonForm/index.d.ts +42 -1
  62. package/dist/jsonForm/index.js +9 -1
  63. package/dist/markdown/index.d.ts +228 -3
  64. package/dist/markdown/index.js +42 -7
  65. package/dist/recommend/index.d.ts +1 -1
  66. package/dist/recommend/index.js +3 -2
  67. package/dist/schemas/index.d.ts +28 -8
  68. package/dist/schemas/index.js +36 -8
  69. package/dist/spatial/index.d.ts +9 -4
  70. package/dist/spatial/index.js +1 -1
  71. package/dist/storage/index.d.ts +2 -2
  72. package/dist/storage/index.js +1 -1
  73. package/dist/story/index.d.ts +1021 -41
  74. package/dist/story/index.js +135 -8
  75. package/dist/{themeLibrary-BHjJ0Qx6.d.ts → themeLibrary-CehcJhzz.d.ts} +1 -1
  76. package/dist/timing/index.js +3 -5
  77. package/dist/transform/index.d.ts +48 -10
  78. package/dist/transform/index.js +17 -481
  79. package/dist/transform/index.js.map +1 -1
  80. package/dist/{types-BdZkdJ3z.d.ts → types-DlAZ7MW4.d.ts} +115 -1
  81. package/dist/versions/index.d.ts +1 -1
  82. package/package.json +6 -1
  83. package/src/__tests__/animationUtils.test.ts +40 -0
  84. package/src/__tests__/annotationCoercion.test.ts +243 -0
  85. package/src/__tests__/applyRenderStyle.test.ts +146 -0
  86. package/src/__tests__/atmosphereLayers.test.ts +108 -0
  87. package/src/__tests__/attrTokens.test.ts +162 -0
  88. package/src/__tests__/contentExtractor.test.ts +60 -0
  89. package/src/__tests__/coverBlock.test.ts +46 -1
  90. package/src/__tests__/customTemplateSample.test.ts +144 -0
  91. package/src/__tests__/customTemplatesFrontmatter.test.ts +236 -0
  92. package/src/__tests__/customThemesFrontmatter.test.ts +145 -0
  93. package/src/__tests__/diagramBlock.test.ts +122 -0
  94. package/src/__tests__/diagramLayout.test.ts +134 -0
  95. package/src/__tests__/drawing.test.ts +109 -0
  96. package/src/__tests__/drawingBlock.test.ts +124 -0
  97. package/src/__tests__/drawingLayout.test.ts +171 -0
  98. package/src/__tests__/geohash.test.ts +6 -5
  99. package/src/__tests__/getLayers.test.ts +6 -2
  100. package/src/__tests__/getLayersFallback.test.ts +103 -0
  101. package/src/__tests__/imageEditExportSvg.test.ts +70 -0
  102. package/src/__tests__/imageEditVersions.test.ts +72 -0
  103. package/src/__tests__/inlineIconMarker.test.ts +36 -0
  104. package/src/__tests__/inputDescriptors.test.ts +162 -0
  105. package/src/__tests__/jsonForm.tokens.test.ts +84 -0
  106. package/src/__tests__/layoutBlock.test.ts +83 -0
  107. package/src/__tests__/layoutLayout.test.ts +169 -0
  108. package/src/__tests__/markdown.test.ts +87 -0
  109. package/src/__tests__/markdownSanitize.test.ts +79 -0
  110. package/src/__tests__/markdownToDoc.test.ts +391 -2
  111. package/src/__tests__/mediaAnnotations.test.ts +91 -0
  112. package/src/__tests__/mediaSchedule.test.ts +118 -0
  113. package/src/__tests__/pandocAttrParse.test.ts +187 -0
  114. package/src/__tests__/resolveDocTheme.test.ts +55 -0
  115. package/src/__tests__/rootExports.test.ts +43 -0
  116. package/src/__tests__/shapeGeometry.test.ts +89 -0
  117. package/src/__tests__/shapeVocabulary.test.ts +133 -0
  118. package/src/__tests__/structuredData.test.ts +249 -0
  119. package/src/__tests__/templateAnnotationParse.test.ts +108 -0
  120. package/src/__tests__/templateMetadata.test.ts +31 -0
  121. package/src/__tests__/templates.test.ts +257 -4
  122. package/src/__tests__/themeCompile.test.ts +42 -0
  123. package/src/__tests__/themeValidator.test.ts +6 -0
  124. package/src/__tests__/transformV2.test.ts +137 -0
  125. package/src/__tests__/transitionNormalize.test.ts +61 -0
  126. package/src/__tests__/validateDoc.test.ts +260 -0
  127. package/src/doc/annotationBlocks.ts +117 -0
  128. package/src/doc/customTemplatesFrontmatter.ts +314 -0
  129. package/src/doc/customThemesFrontmatter.ts +95 -0
  130. package/src/doc/docToMarkdown.ts +104 -5
  131. package/src/doc/getLayers.ts +49 -17
  132. package/src/doc/index.ts +47 -1
  133. package/src/doc/markdownToDoc.ts +411 -13
  134. package/src/doc/mediaAnnotations.ts +100 -0
  135. package/src/doc/resolveDocTheme.ts +55 -0
  136. package/src/doc/standaloneAnnotation.ts +58 -0
  137. package/src/doc/structuredData.ts +301 -0
  138. package/src/doc/templateInputs.ts +240 -0
  139. package/src/doc/templates/__tests__/customTemplate.test.ts +170 -0
  140. package/src/doc/templates/__tests__/customTemplateRegistry.test.ts +110 -0
  141. package/src/doc/templates/accentImage.ts +30 -6
  142. package/src/doc/templates/captionUtils.ts +23 -0
  143. package/src/doc/templates/comparisonBar.ts +33 -18
  144. package/src/doc/templates/coverBlock.ts +35 -5
  145. package/src/doc/templates/customTemplate.ts +51 -0
  146. package/src/doc/templates/dataTable.ts +37 -19
  147. package/src/doc/templates/dateEvent.ts +41 -20
  148. package/src/doc/templates/definitionCard.ts +26 -18
  149. package/src/doc/templates/diagramBlock.ts +204 -0
  150. package/src/doc/templates/diagramLayout.ts +159 -0
  151. package/src/doc/templates/drawingBlock.ts +325 -0
  152. package/src/doc/templates/drawingLayout.ts +539 -0
  153. package/src/doc/templates/factCard.ts +54 -26
  154. package/src/doc/templates/fallbackBlock.ts +123 -0
  155. package/src/doc/templates/featureBlock.ts +20 -13
  156. package/src/doc/templates/fullBleedQuote.ts +18 -8
  157. package/src/doc/templates/imageWithCaption.ts +30 -15
  158. package/src/doc/templates/index.ts +225 -46
  159. package/src/doc/templates/inputDescriptors.ts +432 -0
  160. package/src/doc/templates/layoutBlock.ts +43 -0
  161. package/src/doc/templates/layoutLayout.ts +243 -0
  162. package/src/doc/templates/listBlock.ts +56 -30
  163. package/src/doc/templates/mapBlock.ts +15 -12
  164. package/src/doc/templates/metadata.ts +120 -0
  165. package/src/doc/templates/persistentLayers.ts +129 -11
  166. package/src/doc/templates/photoGrid.ts +22 -34
  167. package/src/doc/templates/pullQuote.ts +30 -12
  168. package/src/doc/templates/quoteBlock.ts +54 -29
  169. package/src/doc/templates/sectionHeader.ts +25 -11
  170. package/src/doc/templates/statHighlight.ts +39 -22
  171. package/src/doc/templates/titleBlock.ts +59 -21
  172. package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +487 -0
  173. package/src/doc/templates/tokens/resolveTokens.ts +453 -0
  174. package/src/doc/templates/twoColumn.ts +48 -30
  175. package/src/doc/templates/videoPullQuote.ts +22 -12
  176. package/src/doc/templates/videoWithCaption.ts +21 -15
  177. package/src/doc/utils/animationUtils.ts +267 -3
  178. package/src/doc/utils/applyRenderStyle.ts +113 -0
  179. package/src/doc/utils/imageTreatment.ts +70 -0
  180. package/src/doc/utils/nearest.ts +59 -0
  181. package/src/doc/utils/shapeGeometry.ts +464 -0
  182. package/src/doc/utils/themeUtils.ts +75 -1
  183. package/src/doc/validate.ts +575 -0
  184. package/src/generate/contentExtractor.ts +148 -35
  185. package/src/generate/index.ts +0 -3
  186. package/src/icons/index.ts +8 -0
  187. package/src/icons/inlineIconMarker.ts +73 -0
  188. package/src/imageEdit/export.ts +85 -2
  189. package/src/imageEdit/index.ts +1 -0
  190. package/src/imageEdit/persistence.ts +51 -0
  191. package/src/index.ts +2 -0
  192. package/src/jsonForm/index.ts +2 -0
  193. package/src/jsonForm/tokens.ts +78 -0
  194. package/src/markdown/annotationCoercion.ts +295 -0
  195. package/src/markdown/attrTokens.ts +241 -0
  196. package/src/markdown/convert.ts +229 -26
  197. package/src/markdown/index.ts +37 -0
  198. package/src/markdown/sanitize.ts +318 -0
  199. package/src/markdown/stringify.ts +96 -6
  200. package/src/markdown/types.ts +66 -0
  201. package/src/markdown/utils.ts +111 -2
  202. package/src/recommend/templates.ts +24 -0
  203. package/src/schemas/BlockTemplates.ts +133 -4
  204. package/src/schemas/CustomTemplates.ts +126 -0
  205. package/src/schemas/Doc.ts +387 -65
  206. package/src/schemas/ImageEditDoc.ts +11 -4
  207. package/src/schemas/Media.ts +169 -0
  208. package/src/schemas/Theme.ts +23 -1
  209. package/src/schemas/Transitions.ts +224 -0
  210. package/src/schemas/colorUtils.ts +33 -0
  211. package/src/schemas/fontStacks.ts +5 -2
  212. package/src/schemas/index.ts +3 -0
  213. package/src/schemas/themeCompile.ts +24 -5
  214. package/src/schemas/themeValidator.ts +49 -11
  215. package/src/schemas/themes/cinematic.json +69 -12
  216. package/src/schemas/themes/documentary.json +58 -11
  217. package/src/schemas/themes/gezellig.json +60 -11
  218. package/src/schemas/themes/magazine.json +54 -9
  219. package/src/schemas/themes/morning-light.json +52 -9
  220. package/src/schemas/themes/standard-dark.json +48 -13
  221. package/src/schemas/themes/standard.json +44 -12
  222. package/src/schemas/themes/tech-dark.json +65 -12
  223. package/src/spatial/Geohash.ts +52 -21
  224. package/src/storage/ContentContainer.ts +6 -2
  225. package/src/transform/applyTransform.ts +43 -4
  226. package/src/transform/index.ts +1 -0
  227. package/src/transform/registry.ts +44 -13
  228. package/src/transform/styles/dataDriven.ts +2 -1
  229. package/src/transform/styles/documentary.ts +2 -1
  230. package/src/transform/styles/magazine.ts +3 -1
  231. package/src/transform/styles/minimal.ts +2 -1
  232. package/src/transform/styles/narrative.ts +3 -1
  233. package/src/transform/templateSelector.ts +87 -11
  234. package/src/transform/types.ts +35 -2
  235. package/dist/chunk-2TMAWB4Y.js.map +0 -1
  236. package/dist/chunk-3E5F2XMR.js.map +0 -1
  237. package/dist/chunk-3K5OG6KI.js.map +0 -1
  238. package/dist/chunk-4X3JQXNM.js +0 -35
  239. package/dist/chunk-4X3JQXNM.js.map +0 -1
  240. package/dist/chunk-57CVQCFX.js.map +0 -1
  241. package/dist/chunk-5WFRKURX.js.map +0 -1
  242. package/dist/chunk-7UDSRZKG.js.map +0 -1
  243. package/dist/chunk-B4EEON3N.js.map +0 -1
  244. package/dist/chunk-FR2RBTKO.js.map +0 -1
  245. package/dist/chunk-H3AXU4MJ.js +0 -179
  246. package/dist/chunk-H3AXU4MJ.js.map +0 -1
  247. package/dist/chunk-K32VJONL.js.map +0 -1
  248. package/dist/chunk-KSWJR66U.js +0 -3706
  249. package/dist/chunk-KSWJR66U.js.map +0 -1
  250. package/dist/chunk-MYIH7FWD.js +0 -145
  251. package/dist/chunk-MYIH7FWD.js.map +0 -1
  252. package/dist/chunk-ZTX4PFFT.js.map +0 -1
  253. package/src/__tests__/slideshowGenerator.test.ts +0 -128
  254. package/src/generate/slideshowGenerator.ts +0 -273
@@ -147,12 +147,66 @@ const QUOTE_PATTERNS = [
147
147
  /(\w+(?:\s+\w+)?)\s+(?:said|stated|wrote|noted|observed|commented),?\s*"([^"]+)"/gi,
148
148
  ];
149
149
 
150
+ // A single side of a comparison ("X" in "from X to Y"). Deliberately excludes
151
+ // sentence/clause terminators (. ! ? ;) and newlines so a capture group can
152
+ // never swallow text from an adjacent sentence. Without this, greedy `.{2,30}`
153
+ // matches across a period — e.g. "…contradicted from witness to witness.
154
+ // Robertson said…" yields right="witness. Robertson", producing a nonsensical
155
+ // "witness → witness. Robertson" slide. Commas and colons are allowed (ranges
156
+ // like "9:00 to 17:00", ratios); downstream label truncation trims commas.
157
+ const COMPARISON_SIDE = '[^.!?;\\r\\n]{2,30}';
158
+
150
159
  const COMPARISON_PATTERNS = [
151
- /from\s+(.{2,30})\s+to\s+(.{2,30})/gi,
152
- /(.{2,30})\s+(?:vs\.?|versus)\s+(.{2,30})/gi,
153
- /(.{2,30})\s+compared\s+to\s+(.{2,30})/gi,
160
+ new RegExp(`from\\s+(${COMPARISON_SIDE})\\s+to\\s+(${COMPARISON_SIDE})`, 'gi'),
161
+ new RegExp(`(${COMPARISON_SIDE})\\s+(?:vs\\.?|versus)\\s+(${COMPARISON_SIDE})`, 'gi'),
162
+ new RegExp(`(${COMPARISON_SIDE})\\s+compared\\s+to\\s+(${COMPARISON_SIDE})`, 'gi'),
154
163
  ];
155
164
 
165
+ // Words whose trailing period is an abbreviation, not a sentence end. Used by
166
+ // isSentenceBoundary() so names like "George W. Tibbetts" or "Quong Chong & Co."
167
+ // don't get split into fragments. Matched only when the entire preceding word
168
+ // equals one of these (case-insensitive), so "canal." / "taco." are unaffected.
169
+ const SENTENCE_ABBREVIATIONS = new Set([
170
+ 'mr',
171
+ 'mrs',
172
+ 'ms',
173
+ 'dr',
174
+ 'prof',
175
+ 'st',
176
+ 'mt',
177
+ 'jr',
178
+ 'sr',
179
+ 'gen',
180
+ 'col',
181
+ 'capt',
182
+ 'lt',
183
+ 'sgt',
184
+ 'rev',
185
+ 'hon',
186
+ 'sen',
187
+ 'rep',
188
+ 'gov',
189
+ 'pres',
190
+ 'vs',
191
+ 'etc',
192
+ 'inc',
193
+ 'co',
194
+ 'ltd',
195
+ 'corp',
196
+ 'al',
197
+ 'fig',
198
+ 'eg',
199
+ 'ie',
200
+ 'approx',
201
+ 'dept',
202
+ 'univ',
203
+ 'assn',
204
+ 'bros',
205
+ 'ave',
206
+ 'blvd',
207
+ 'rd',
208
+ ]);
209
+
156
210
  const FACT_INDICATORS = [
157
211
  'largest',
158
212
  'smallest',
@@ -427,10 +481,38 @@ function extractComparisons(text: string): ExtractedElement[] {
427
481
  if (seen.has(position)) continue;
428
482
  seen.add(position);
429
483
 
430
- const left = match[1].trim();
431
- const right = match[2].trim();
484
+ // Trim a trailing clause introduced by a comma + space ("retired
485
+ // airliners, arranged in rows" → "retired airliners"). A greedy side
486
+ // match often swallows the words after the compared thing; the comma is a
487
+ // clause boundary, not part of the value. Numeric grouping commas
488
+ // ("4,000") survive because those are comma+digit, not comma+space.
489
+ const trimClause = (s: string) => s.replace(/,\s+.*$/, '').trim();
490
+ const left = trimClause(match[1].trim());
491
+ const right = trimClause(match[2].trim());
432
492
  if (left.length < 2 || left.length > 30) continue;
433
493
  if (right.length < 2 || right.length > 30) continue;
494
+ // Reject "from X to X"-style idioms — "from witness to witness",
495
+ // "from day to day", "from generation to generation". These mean "it
496
+ // varied", not "A versus B", and render as a pointless "X → X" card.
497
+ // The signature is a word repeated across the connector: the last word
498
+ // of the left side equals the first word of the right side. (Greedy
499
+ // matching may append trailing context to the right side, so a whole-
500
+ // string equality check is not enough.)
501
+ const wordsOf = (s: string) =>
502
+ s
503
+ .toLowerCase()
504
+ .split(/\s+/)
505
+ .map((w) => w.replace(/[^a-z0-9]+/g, ''))
506
+ .filter(Boolean);
507
+ const leftWords = wordsOf(left);
508
+ const rightWords = wordsOf(right);
509
+ if (
510
+ leftWords.length > 0 &&
511
+ rightWords.length > 0 &&
512
+ leftWords[leftWords.length - 1] === rightWords[0]
513
+ ) {
514
+ continue;
515
+ }
434
516
 
435
517
  const context = getSentenceContext(text, position, match[0].length);
436
518
 
@@ -447,46 +529,77 @@ function extractComparisons(text: string): ExtractedElement[] {
447
529
  return elements;
448
530
  }
449
531
 
532
+ /**
533
+ * Whether the `. ! ?` at `index` is a real sentence terminator rather than a
534
+ * period inside an abbreviation ("George W. Tibbetts", "M. DeWitt", "Mr.",
535
+ * "U.S."), a single-letter initial, a decimal ("3.5"), or an ellipsis. Naive
536
+ * splitting on every period produces broken fragments — e.g. the standalone
537
+ * slide "They were met at George W" from "…at George W. Tibbetts' store…".
538
+ * Returns false for any non-terminator character so callers can scan freely.
539
+ */
540
+ function isSentenceBoundary(text: string, index: number): boolean {
541
+ const ch = text[index];
542
+ if (ch === '!' || ch === '?') return true;
543
+ if (ch !== '.') return false;
544
+
545
+ // Ellipsis: defer the boundary to the final dot in a run of dots.
546
+ if (text[index + 1] === '.') return false;
547
+ // Decimal number: digit "." digit → "3.5".
548
+ if (/[0-9]/.test(text[index - 1] ?? '') && /[0-9]/.test(text[index + 1] ?? '')) return false;
549
+
550
+ // The run of letters immediately preceding the period.
551
+ let w = index - 1;
552
+ while (w >= 0 && /[A-Za-z]/.test(text[w])) w--;
553
+ const word = text.slice(w + 1, index);
554
+ if (word.length === 1) return false; // single-letter initial: "George W."
555
+ if (word && SENTENCE_ABBREVIATIONS.has(word.toLowerCase())) return false;
556
+
557
+ // A genuine sentence starts with an uppercase letter, digit, or opening
558
+ // quote/bracket. A lowercase continuation means the period was mid-sentence
559
+ // (an abbreviation we didn't enumerate).
560
+ let j = index + 1;
561
+ while (j < text.length && /\s/.test(text[j])) j++;
562
+ if (j >= text.length) return true; // end of text
563
+ return !/[a-z]/.test(text[j]);
564
+ }
565
+
450
566
  /**
451
567
  * Iterate over sentence-like spans in the text, yielding trimmed text and
452
568
  * accurate start/end indices within the original string.
453
569
  *
454
- * Sentences are approximated as runs of non-[.!?] characters optionally
455
- * followed by [.!?] delimiters. Leading/trailing whitespace is excluded.
570
+ * Boundaries are detected by isSentenceBoundary() so abbreviations, initials,
571
+ * decimals, and ellipses do not split a sentence. Leading whitespace and
572
+ * trailing whitespace/terminators are excluded from each span.
456
573
  */
457
574
  function forEachSentenceSpan(
458
575
  text: string,
459
576
  callback: (trimmed: string, start: number, end: number) => void,
460
577
  ): void {
461
- const sentenceRegex = /[^.!?]+[.!?]*/g;
462
- let match: RegExpExecArray | null;
463
-
464
- while ((match = sentenceRegex.exec(text)) !== null) {
465
- const raw = match[0];
466
- const rawStart = match.index;
467
- const rawEnd = rawStart + raw.length;
468
-
469
- // Compute leading whitespace to exclude
470
- let leading = 0;
471
- while (leading < raw.length && /\s/.test(raw[leading])) {
472
- leading++;
473
- }
474
-
475
- // Compute trailing whitespace or sentence-ending punctuation to exclude
476
- let trailing = 0;
477
- while (trailing < raw.length - leading && /[\s.!?]/.test(raw[raw.length - 1 - trailing])) {
478
- trailing++;
479
- }
480
-
481
- const start = rawStart + leading;
482
- const end = rawEnd - trailing;
483
- if (end <= start) continue;
484
-
578
+ const emit = (rawStart: number, rawEnd: number) => {
579
+ let start = rawStart;
580
+ while (start < rawEnd && /\s/.test(text[start])) start++;
581
+ let end = rawEnd;
582
+ while (end > start && /[\s.!?]/.test(text[end - 1])) end--;
583
+ if (end <= start) return;
485
584
  const trimmed = text.slice(start, end);
486
- if (!trimmed) continue;
585
+ if (trimmed) callback(trimmed, start, end);
586
+ };
487
587
 
488
- callback(trimmed, start, end);
588
+ let spanStart = 0;
589
+ let i = 0;
590
+ while (i < text.length) {
591
+ if (isSentenceBoundary(text, i)) {
592
+ // Consume any consecutive terminators ("?!", "…") as one boundary.
593
+ let end = i + 1;
594
+ while (end < text.length && /[.!?]/.test(text[end])) end++;
595
+ emit(spanStart, end);
596
+ spanStart = end;
597
+ i = end;
598
+ } else {
599
+ i++;
600
+ }
489
601
  }
602
+ if (spanStart < text.length) emit(spanStart, text.length);
490
603
  }
491
604
 
492
605
  function extractFacts(text: string): ExtractedElement[] {
@@ -621,11 +734,11 @@ function getSentenceContext(
621
734
  matchLength: number,
622
735
  ): { sentence: string; start: number; end: number } {
623
736
  let start = matchStart;
624
- while (start > 0 && !/[.!?]/.test(text[start - 1])) start--;
737
+ while (start > 0 && !isSentenceBoundary(text, start - 1)) start--;
625
738
  while (start < matchStart && /\s/.test(text[start])) start++;
626
739
 
627
740
  let end = matchStart + matchLength;
628
- while (end < text.length && !/[.!?]/.test(text[end])) end++;
741
+ while (end < text.length && !isSentenceBoundary(text, end)) end++;
629
742
  if (end < text.length) end++;
630
743
 
631
744
  return { sentence: text.slice(start, end).trim(), start, end };
@@ -16,6 +16,3 @@ export type {
16
16
 
17
17
  export { mapElementToBlock } from './templateMapper.js';
18
18
  export type { MapOptions } from './templateMapper.js';
19
-
20
- export { generateSlideshow } from './slideshowGenerator.js';
21
- export type { SlideshowDoc, SlideshowImage, SlideshowOptions } from './slideshowGenerator.js';
@@ -16,3 +16,11 @@ export {
16
16
  iconGlyph,
17
17
  } from './resolve.js';
18
18
  export type { IconSuggestion } from './resolve.js';
19
+ export {
20
+ iconMarker,
21
+ hasIconMarker,
22
+ stripIconMarkers,
23
+ splitIconMarkers,
24
+ iconClass,
25
+ } from './inlineIconMarker.js';
26
+ export type { IconTextRun } from './inlineIconMarker.js';
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Inline-icon markers for template text.
3
+ *
4
+ * Slide templates flatten rich markdown body content into strings before
5
+ * rendering. Inline icons need to survive that flattening step, so we encode
6
+ * them as sentinel-delimited text markers and let `TextLayer` turn them back
7
+ * into FontAwesome glyphs when rendering.
8
+ */
9
+
10
+ import type { IconFamily } from './iconData.js';
11
+
12
+ /**
13
+ * Private-Use-Area sentinel that brackets an encoded icon.
14
+ * Kept as an escape sequence so the source remains valid TypeScript text.
15
+ */
16
+ const SENTINEL = '\uE000';
17
+
18
+ /** Matches one encoded marker: `<sentinel><family>:<name><sentinel>`. */
19
+ const MARKER_RE = new RegExp(`${SENTINEL}([a-z]+):([a-zA-Z0-9-]+)${SENTINEL}`, 'g');
20
+
21
+ /** Encode a resolved icon (family + name) as an embeddable text marker. */
22
+ export function iconMarker(family: IconFamily, name: string): string {
23
+ return `${SENTINEL}${family}:${name}${SENTINEL}`;
24
+ }
25
+
26
+ /** True when a string contains at least one encoded icon marker. */
27
+ export function hasIconMarker(value: string): boolean {
28
+ return value.includes(SENTINEL);
29
+ }
30
+
31
+ /** Remove every icon marker, leaving a clean plain-text projection. */
32
+ export function stripIconMarkers(value: string): string {
33
+ return value.replace(MARKER_RE, '');
34
+ }
35
+
36
+ /** A run of a marker-bearing string: literal text or a resolved icon. */
37
+ export type IconTextRun =
38
+ | { type: 'text'; text: string }
39
+ | { type: 'icon'; family: IconFamily; name: string };
40
+
41
+ /**
42
+ * Split a string into ordered text/icon runs. Text between markers is
43
+ * preserved verbatim, including whitespace; each marker becomes an icon run.
44
+ */
45
+ export function splitIconMarkers(value: string): IconTextRun[] {
46
+ const runs: IconTextRun[] = [];
47
+ let lastIndex = 0;
48
+ MARKER_RE.lastIndex = 0;
49
+
50
+ let match: RegExpExecArray | null;
51
+ while ((match = MARKER_RE.exec(value)) !== null) {
52
+ if (match.index > lastIndex) {
53
+ runs.push({ type: 'text', text: value.slice(lastIndex, match.index) });
54
+ }
55
+ runs.push({
56
+ type: 'icon',
57
+ family: match[1] as IconFamily,
58
+ name: match[2],
59
+ });
60
+ lastIndex = match.index + match[0].length;
61
+ }
62
+
63
+ if (lastIndex < value.length) {
64
+ runs.push({ type: 'text', text: value.slice(lastIndex) });
65
+ }
66
+
67
+ return runs;
68
+ }
69
+
70
+ /** FontAwesome CSS class for a resolved icon, e.g. `fa-solid fa-rocket`. */
71
+ export function iconClass(family: IconFamily, name: string): string {
72
+ return `fa-${family} fa-${name}`;
73
+ }
@@ -15,7 +15,8 @@
15
15
 
16
16
  import type { ContentContainer } from '../storage/ContentContainer.js';
17
17
  import type { ImageEditCanvas, ImageEditDoc, ImageEditLayer } from '../schemas/ImageEditDoc.js';
18
- import type { Position } from '../schemas/Doc.js';
18
+ import type { MarkerStyle, Position } from '../schemas/Doc.js';
19
+ import { markerPath, shapePath } from '../doc/utils/shapeGeometry.js';
19
20
 
20
21
  /** Supported export formats. */
21
22
  export type ImageEditExportFormat = 'png' | 'jpeg' | 'webp';
@@ -78,7 +79,15 @@ export async function exportImageEditDoc(
78
79
  // SVG construction
79
80
  // ============================================
80
81
 
81
- async function buildSvgString(doc: ImageEditDoc, container: ContentContainer): Promise<string> {
82
+ /**
83
+ * Build the standalone SVG string that mirrors the canvas + layers. Exposed
84
+ * for tests (the raster step needs a browser); shape/text/path layers don't
85
+ * touch `container`, only image layers do (to inline asset bytes).
86
+ */
87
+ export async function buildSvgString(
88
+ doc: ImageEditDoc,
89
+ container: ContentContainer,
90
+ ): Promise<string> {
82
91
  const { width, height, background } = doc.canvas;
83
92
  const bgRect =
84
93
  background && background !== 'transparent'
@@ -145,6 +154,10 @@ async function renderLayerInner(
145
154
  return `<line x1="${x}" y1="${y}" x2="${x + w}" y2="${y + h}"${stroke}${strokeWidth}/>`;
146
155
  }
147
156
 
157
+ if (layer.type === 'path') {
158
+ return renderPathToSvg(layer, x, y, w, h);
159
+ }
160
+
148
161
  // text
149
162
  const c = layer.content;
150
163
  const style = c.style;
@@ -169,6 +182,76 @@ async function renderLayerInner(
169
182
  );
170
183
  }
171
184
 
185
+ /**
186
+ * Serialize a `path` layer to SVG. Mirrors the runtime
187
+ * `react/src/layers/PathLayer.tsx`: when `shapeKind` is set, `d` is
188
+ * re-derived from the position box via `shapePath` (so named shapes scale);
189
+ * otherwise the stored absolute `d` is used. Start/end markers are emitted
190
+ * as `<marker>` defs scoped to the layer id, matching `markerPath` geometry.
191
+ */
192
+ function renderPathToSvg(
193
+ layer: ImageEditLayer & { type: 'path' },
194
+ x: number,
195
+ y: number,
196
+ w: number,
197
+ h: number,
198
+ ): string {
199
+ const c = layer.content;
200
+ const d = c.shapeKind ? (shapePath(c.shapeKind, x, y, w, h) ?? c.d) : c.d;
201
+ const stroke = c.stroke ?? '#1e293b';
202
+ const strokeWidth = c.strokeWidth ?? 2;
203
+ const fill = c.fill ?? 'none';
204
+ const dash = c.dasharray ? ` stroke-dasharray="${escapeAttr(c.dasharray)}"` : '';
205
+
206
+ // Markers: explicit start/end win; otherwise derive from the legacy `arrow` flag.
207
+ const startStyle = effectiveExportMarker(c.startMarker, c.arrow, 'start');
208
+ const endStyle = effectiveExportMarker(c.endMarker, c.arrow, 'end');
209
+ const start = markerPath(startStyle, 'start');
210
+ const end = markerPath(endStyle, 'end');
211
+ const startId = `marker-start-${layer.id}`;
212
+ const endId = `marker-end-${layer.id}`;
213
+ const defs =
214
+ start || end
215
+ ? `<defs>${start ? markerDefSvg(startId, 'start', start, stroke) : ''}${
216
+ end ? markerDefSvg(endId, 'end', end, stroke) : ''
217
+ }</defs>`
218
+ : '';
219
+ const markerStart = start ? ` marker-start="url(#${startId})"` : '';
220
+ const markerEnd = end ? ` marker-end="url(#${endId})"` : '';
221
+
222
+ return (
223
+ defs +
224
+ `<path d="${escapeAttr(d)}" fill="${escapeAttr(fill)}"` +
225
+ ` stroke="${escapeAttr(stroke)}" stroke-width="${strokeWidth}"${dash}${markerStart}${markerEnd}/>`
226
+ );
227
+ }
228
+
229
+ function effectiveExportMarker(
230
+ explicit: MarkerStyle | undefined,
231
+ arrow: (ImageEditLayer & { type: 'path' })['content']['arrow'],
232
+ end: 'start' | 'end',
233
+ ): MarkerStyle {
234
+ if (explicit) return explicit;
235
+ return arrow === 'both' || arrow === end ? 'arrow' : 'none';
236
+ }
237
+
238
+ function markerDefSvg(
239
+ id: string,
240
+ dir: 'start' | 'end',
241
+ marker: { d: string; filled: boolean },
242
+ stroke: string,
243
+ ): string {
244
+ const refX = dir === 'end' ? 9 : 1;
245
+ const paint = marker.filled
246
+ ? `fill="${escapeAttr(stroke)}"`
247
+ : `fill="none" stroke="${escapeAttr(stroke)}" stroke-width="1.5"`;
248
+ return (
249
+ `<marker id="${escapeAttr(id)}" viewBox="0 0 10 10" refX="${refX}" refY="5"` +
250
+ ` markerWidth="4" markerHeight="4" orient="auto-start-reverse" markerUnits="strokeWidth">` +
251
+ `<path d="${escapeAttr(marker.d)}" ${paint}/></marker>`
252
+ );
253
+ }
254
+
172
255
  function preserveAspectRatioFor(fit?: 'cover' | 'contain' | 'fill'): string {
173
256
  if (fit === 'cover') return 'xMidYMid slice';
174
257
  if (fit === 'fill') return 'none';
@@ -66,6 +66,7 @@ export {
66
66
  // Export
67
67
  export {
68
68
  exportImageEditDoc,
69
+ buildSvgString,
69
70
  type ImageEditExportFormat,
70
71
  type ImageEditExportOptions,
71
72
  } from './export.js';
@@ -59,4 +59,55 @@ function assertImageEditDoc(value: unknown, filename: string): asserts value is
59
59
  if (!Array.isArray(v.layers)) {
60
60
  throw new Error(`readImageEditDoc: ${filename} layers must be an array`);
61
61
  }
62
+ v.layers.forEach((layer, i) => assertLayer(layer, i, filename));
63
+ }
64
+
65
+ const VALID_LAYER_TYPES = new Set(['image', 'text', 'shape', 'path']);
66
+
67
+ function assertLayer(value: unknown, index: number, filename: string): void {
68
+ const ctx = `${filename} layers[${index}]`;
69
+ if (!value || typeof value !== 'object') {
70
+ throw new Error(`readImageEditDoc: ${ctx} must be an object`);
71
+ }
72
+ const layer = value as Record<string, unknown>;
73
+
74
+ if (typeof layer['id'] !== 'string') {
75
+ throw new Error(`readImageEditDoc: ${ctx} must have a string id`);
76
+ }
77
+ if (typeof layer['type'] !== 'string' || !VALID_LAYER_TYPES.has(layer['type'])) {
78
+ throw new Error(
79
+ `readImageEditDoc: ${ctx} type must be one of image|text|shape, got ${String(layer['type'])}`,
80
+ );
81
+ }
82
+
83
+ const pos = layer['position'];
84
+ if (
85
+ !pos ||
86
+ typeof pos !== 'object' ||
87
+ (typeof (pos as Record<string, unknown>)['x'] !== 'number' &&
88
+ typeof (pos as Record<string, unknown>)['x'] !== 'string') ||
89
+ (typeof (pos as Record<string, unknown>)['y'] !== 'number' &&
90
+ typeof (pos as Record<string, unknown>)['y'] !== 'string')
91
+ ) {
92
+ throw new Error(`readImageEditDoc: ${ctx} position must have numeric or string x and y`);
93
+ }
94
+
95
+ const content = layer['content'];
96
+ if (!content || typeof content !== 'object') {
97
+ throw new Error(`readImageEditDoc: ${ctx} content must be an object`);
98
+ }
99
+ const c = content as Record<string, unknown>;
100
+
101
+ if (layer['type'] === 'image' && typeof c['src'] !== 'string') {
102
+ throw new Error(`readImageEditDoc: ${ctx} (image) content.src must be a string`);
103
+ }
104
+ if (layer['type'] === 'text' && typeof c['text'] !== 'string') {
105
+ throw new Error(`readImageEditDoc: ${ctx} (text) content.text must be a string`);
106
+ }
107
+ if (layer['type'] === 'shape' && typeof c['shape'] !== 'string') {
108
+ throw new Error(`readImageEditDoc: ${ctx} (shape) content.shape must be a string`);
109
+ }
110
+ if (layer['type'] === 'path' && typeof c['d'] !== 'string') {
111
+ throw new Error(`readImageEditDoc: ${ctx} (path) content.d must be a string`);
112
+ }
62
113
  }
package/src/index.ts CHANGED
@@ -6,7 +6,9 @@ export * from './markdown/index.js';
6
6
  export * from './timing/index.js';
7
7
  export * from './random/index.js';
8
8
  export * from './generate/index.js';
9
+ export * from './transform/index.js';
9
10
  export * from './versions/index.js';
10
11
  export * from './jsonForm/index.js';
11
12
  export * from './imageEdit/index.js';
13
+ export * from './icons/index.js';
12
14
  export * from './recommend/index.js';
@@ -20,3 +20,5 @@ export { evaluateWhen, resolveFlag } from './evaluateWhen.js';
20
20
  export { toPointer, pointerSegments, getByPointer, setByPointer, resolveRef } from './pointer.js';
21
21
  export { inferSchema } from './inferSchema.js';
22
22
  export type { InferSchemaOptions } from './inferSchema.js';
23
+ export { buildJsonFormTokens, resolveJsonFormTheme } from './tokens.js';
24
+ export type { BuildJsonFormTokensOptions } from './tokens.js';
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Pure CSS-custom-property builder shared by `<JsonView>` (read-only, in
3
+ * `@bendyline/squisq-react`) and `<JsonEditor>` (editable, in
4
+ * `@bendyline/squisq-editor-react`).
5
+ *
6
+ * Both components derive an identical token bag from a Theme + resolved
7
+ * Surface; they differ only in the CSS-var prefix (`--squisq-json` vs
8
+ * `--squisq-jsonform`) so the viewer and editor can coexist on one page.
9
+ * This function is the single source of that derivation. It is pure and
10
+ * framework-free: it performs no `window` / `matchMedia` access — the
11
+ * surface must already be resolved to a concrete `SurfaceScheme` (or left
12
+ * `undefined` to skip surface application) by the calling React hook.
13
+ */
14
+
15
+ import { applySurface, DEFAULT_THEME, resolveFontFamily } from '../schemas/index.js';
16
+ import type { SurfaceScheme, Theme } from '../schemas/index.js';
17
+
18
+ export interface BuildJsonFormTokensOptions {
19
+ /**
20
+ * CSS-var prefix WITHOUT the trailing dash, e.g. `--squisq-json` or
21
+ * `--squisq-jsonform`. Each token key is `${prefix}-${name}`.
22
+ */
23
+ prefix: string;
24
+ }
25
+
26
+ /**
27
+ * Build the CSS custom-property map for a JSON form/view surface.
28
+ *
29
+ * @param theme The theme to render with (falls back to `DEFAULT_THEME`).
30
+ * @param surface Already-resolved concrete surface, or `undefined` to leave
31
+ * the theme un-surfaced. (The `'auto'` → concrete resolution
32
+ * is the React hook's job, so this stays pure.)
33
+ * @param options `{ prefix }` — the CSS-var namespace.
34
+ */
35
+ export function buildJsonFormTokens(
36
+ theme: Theme | undefined,
37
+ surface: SurfaceScheme | undefined,
38
+ options: BuildJsonFormTokensOptions,
39
+ ): Record<string, string> {
40
+ const { prefix } = options;
41
+ const baseTheme = theme ?? DEFAULT_THEME;
42
+ const finalTheme = surface ? applySurface(baseTheme, surface) : baseTheme;
43
+
44
+ const titleFont = resolveFontFamily(finalTheme.typography.titleFont, 'system-ui, sans-serif');
45
+ const bodyFont = resolveFontFamily(finalTheme.typography.bodyFont, 'system-ui, sans-serif');
46
+ const monoFont = resolveFontFamily(
47
+ finalTheme.typography.monoFont,
48
+ 'ui-monospace, Consolas, monospace',
49
+ );
50
+
51
+ return {
52
+ [`${prefix}-bg`]: finalTheme.colors.background,
53
+ [`${prefix}-text`]: finalTheme.colors.text,
54
+ [`${prefix}-muted`]: finalTheme.colors.textMuted,
55
+ [`${prefix}-primary`]: finalTheme.colors.primary,
56
+ [`${prefix}-accent`]: finalTheme.colors.secondary,
57
+ [`${prefix}-warning`]: finalTheme.colors.warning,
58
+ [`${prefix}-border`]: `color-mix(in srgb, ${finalTheme.colors.textMuted} 35%, transparent)`,
59
+ [`${prefix}-input-bg`]: finalTheme.colors.backgroundLight,
60
+ [`${prefix}-title-font`]: titleFont,
61
+ [`${prefix}-body-font`]: bodyFont,
62
+ [`${prefix}-mono-font`]: monoFont,
63
+ [`${prefix}-radius`]: `${finalTheme.style.borderRadius ?? 8}px`,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Resolve the effective theme after surface application — the companion to
69
+ * `buildJsonFormTokens` for callers that also need the `Theme` object (both
70
+ * hooks return it alongside the style bag).
71
+ */
72
+ export function resolveJsonFormTheme(
73
+ theme: Theme | undefined,
74
+ surface: SurfaceScheme | undefined,
75
+ ): Theme {
76
+ const baseTheme = theme ?? DEFAULT_THEME;
77
+ return surface ? applySurface(baseTheme, surface) : baseTheme;
78
+ }