@bendyline/squisq-formats 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +20 -0
  3. package/README.md +1 -1
  4. package/dist/{chunk-NNHKUXKA.js → chunk-26ISNJ7Y.js} +85 -65
  5. package/dist/{chunk-NKAJPJ4G.js → chunk-2JJ5RFDZ.js} +0 -1
  6. package/dist/{chunk-WQSHGBLN.js → chunk-3NKXBZSR.js} +193 -42
  7. package/dist/{chunk-KURGXM4I.js → chunk-4V3KCHAP.js} +3 -4
  8. package/dist/{chunk-MLX2BOJC.js → chunk-6RQOV3B3.js} +1 -2
  9. package/dist/{chunk-EW54IRRS.js → chunk-6S6GU3ZG.js} +5 -6
  10. package/dist/{chunk-FE6OJV6O.js → chunk-7AWFHP5U.js} +1 -1
  11. package/dist/{chunk-RFAPOKHJ.js → chunk-AD2WT564.js} +59 -9
  12. package/dist/{chunk-O3GVVND4.js → chunk-AONELFLA.js} +0 -1
  13. package/dist/{chunk-SC67HYQJ.js → chunk-EJTNGKEA.js} +5 -8
  14. package/dist/chunk-GX7RAUME.js +121 -0
  15. package/dist/{chunk-SSUPBUF5.js → chunk-IIQYS2YH.js} +0 -1
  16. package/dist/{chunk-RLU7UFYU.js → chunk-IPN56VLW.js} +83 -58
  17. package/dist/{chunk-DTDF6QDP.js → chunk-JE6LSIHE.js} +81 -20
  18. package/dist/{chunk-U4MRIFKL.js → chunk-JU2RHXUB.js} +0 -1
  19. package/dist/{chunk-4VUWTSGM.js → chunk-K6XRMVPW.js} +64 -31
  20. package/dist/{chunk-ODL3SSPT.js → chunk-KXOZMWBS.js} +0 -1
  21. package/dist/chunk-OGS5VCGJ.js +446 -0
  22. package/dist/{chunk-GVS2XXV6.js → chunk-PJXJI2LY.js} +449 -57
  23. package/dist/{chunk-74GO3FVS.js → chunk-PU7REGWV.js} +5 -8
  24. package/dist/{chunk-PN52A5AA.js → chunk-SBUW7NHR.js} +0 -1
  25. package/dist/{chunk-QFLDYKCR.js → chunk-TAAENIRB.js} +5 -8
  26. package/dist/{chunk-7ARKUCQT.js → chunk-X2DEAXNK.js} +54 -2
  27. package/dist/container/index.js +1 -2
  28. package/dist/csv/index.d.ts +27 -2
  29. package/dist/csv/index.js +1 -2
  30. package/dist/docx/index.d.ts +5 -1
  31. package/dist/docx/index.js +9 -11
  32. package/dist/epub/index.d.ts +2 -0
  33. package/dist/epub/index.js +5 -6
  34. package/dist/{export-D2NkylDT.d.ts → export-D9msROJS.d.ts} +18 -6
  35. package/dist/extract-MN7LA3NL.js +13 -0
  36. package/dist/html/index.d.ts +11 -4
  37. package/dist/html/index.js +3 -4
  38. package/dist/images-ESPQKVTW.js +6 -0
  39. package/dist/{import-K8mfc0fz.d.ts → import-C3htUTss.d.ts} +5 -1
  40. package/dist/{import-DTkDxHmZ.d.ts → import-C8whCC7_.d.ts} +6 -0
  41. package/dist/index.d.ts +7 -7
  42. package/dist/index.js +28 -26
  43. package/dist/infer/index.d.ts +3 -3
  44. package/dist/infer/index.js +7 -9
  45. package/dist/{layouts-BHrgZ5FS.d.ts → layouts-CTdPlB-u.d.ts} +1 -1
  46. package/dist/layouts-DRWZGSPD.js +10 -0
  47. package/dist/{mapTheme-IR27S6IV.js → mapTheme-4TWH25FT.js} +1 -2
  48. package/dist/ooxml/index.d.ts +3 -3
  49. package/dist/ooxml/index.js +14 -13
  50. package/dist/pdf/index.d.ts +18 -0
  51. package/dist/pdf/index.js +2 -3
  52. package/dist/pptx/index.d.ts +4 -4
  53. package/dist/pptx/index.js +11 -13
  54. package/dist/{reader-B9L8Ucbj.d.ts → reader-B_m1aKZC.d.ts} +30 -1
  55. package/dist/registry/index.d.ts +21 -5
  56. package/dist/registry/index.js +9 -6
  57. package/dist/{themeReader-DJKErl_j.d.ts → themeReader-DCtwC83Q.d.ts} +1 -1
  58. package/dist/xlsx/index.d.ts +3 -3
  59. package/dist/xlsx/index.js +6 -7
  60. package/package.json +6 -3
  61. package/dist/chunk-4VUWTSGM.js.map +0 -1
  62. package/dist/chunk-6M7Z25LA.js +0 -46
  63. package/dist/chunk-6M7Z25LA.js.map +0 -1
  64. package/dist/chunk-74GO3FVS.js.map +0 -1
  65. package/dist/chunk-7ARKUCQT.js.map +0 -1
  66. package/dist/chunk-DTDF6QDP.js.map +0 -1
  67. package/dist/chunk-EW54IRRS.js.map +0 -1
  68. package/dist/chunk-FE6OJV6O.js.map +0 -1
  69. package/dist/chunk-GVS2XXV6.js.map +0 -1
  70. package/dist/chunk-KURGXM4I.js.map +0 -1
  71. package/dist/chunk-MLX2BOJC.js.map +0 -1
  72. package/dist/chunk-NKAJPJ4G.js.map +0 -1
  73. package/dist/chunk-NNHKUXKA.js.map +0 -1
  74. package/dist/chunk-O3GVVND4.js.map +0 -1
  75. package/dist/chunk-ODL3SSPT.js.map +0 -1
  76. package/dist/chunk-PN52A5AA.js.map +0 -1
  77. package/dist/chunk-QFLDYKCR.js.map +0 -1
  78. package/dist/chunk-RFAPOKHJ.js.map +0 -1
  79. package/dist/chunk-RLU7UFYU.js.map +0 -1
  80. package/dist/chunk-SC67HYQJ.js.map +0 -1
  81. package/dist/chunk-SSUPBUF5.js.map +0 -1
  82. package/dist/chunk-U4MRIFKL.js.map +0 -1
  83. package/dist/chunk-UGYF5AZE.js +0 -275
  84. package/dist/chunk-UGYF5AZE.js.map +0 -1
  85. package/dist/chunk-WQSHGBLN.js.map +0 -1
  86. package/dist/chunk-YRT7GQ5Y.js +0 -28
  87. package/dist/chunk-YRT7GQ5Y.js.map +0 -1
  88. package/dist/container/index.js.map +0 -1
  89. package/dist/csv/index.js.map +0 -1
  90. package/dist/docx/index.js.map +0 -1
  91. package/dist/epub/index.js.map +0 -1
  92. package/dist/extract-OJ7ZQV6P.js +0 -15
  93. package/dist/extract-OJ7ZQV6P.js.map +0 -1
  94. package/dist/html/index.js.map +0 -1
  95. package/dist/images-7FBWPKE3.js +0 -7
  96. package/dist/images-7FBWPKE3.js.map +0 -1
  97. package/dist/index.js.map +0 -1
  98. package/dist/infer/index.js.map +0 -1
  99. package/dist/layouts-5VDIRPIJ.js +0 -12
  100. package/dist/layouts-5VDIRPIJ.js.map +0 -1
  101. package/dist/mapTheme-IR27S6IV.js.map +0 -1
  102. package/dist/ooxml/index.js.map +0 -1
  103. package/dist/pdf/index.js.map +0 -1
  104. package/dist/pptx/index.js.map +0 -1
  105. package/dist/registry/index.js.map +0 -1
  106. package/dist/xlsx/index.js.map +0 -1
  107. package/src/__tests__/container.test.ts +0 -230
  108. package/src/__tests__/convert.test.ts +0 -495
  109. package/src/__tests__/csvImport.test.ts +0 -84
  110. package/src/__tests__/docxExport.test.ts +0 -491
  111. package/src/__tests__/docxImport.test.ts +0 -531
  112. package/src/__tests__/epub.test.ts +0 -649
  113. package/src/__tests__/exportThemeReconciliation.test.ts +0 -87
  114. package/src/__tests__/formatRegistry.test.ts +0 -174
  115. package/src/__tests__/html.test.ts +0 -439
  116. package/src/__tests__/htmlImport.test.ts +0 -57
  117. package/src/__tests__/inferTheme.test.ts +0 -135
  118. package/src/__tests__/lossyWarnings.test.ts +0 -146
  119. package/src/__tests__/ooxml.test.ts +0 -271
  120. package/src/__tests__/ooxmlCancellation.test.ts +0 -113
  121. package/src/__tests__/ooxmlThemeReader.test.ts +0 -92
  122. package/src/__tests__/pdfExport.test.ts +0 -322
  123. package/src/__tests__/pdfImport.test.ts +0 -384
  124. package/src/__tests__/plainHtml.test.ts +0 -417
  125. package/src/__tests__/plainHtmlBundle.test.ts +0 -253
  126. package/src/__tests__/pptxExport.test.ts +0 -138
  127. package/src/__tests__/pptxImport.test.ts +0 -145
  128. package/src/__tests__/pptxInferFixtures.ts +0 -314
  129. package/src/__tests__/pptxLayoutInfer.test.ts +0 -395
  130. package/src/__tests__/roundTrip.test.ts +0 -201
  131. package/src/__tests__/roundTripAssets.test.ts +0 -50
  132. package/src/__tests__/roundTripMatrix.fixtures.ts +0 -86
  133. package/src/__tests__/roundTripMatrix.helpers.ts +0 -154
  134. package/src/__tests__/roundTripMatrix.test.ts +0 -142
  135. package/src/__tests__/sharedContainer.test.ts +0 -41
  136. package/src/__tests__/sharedImages.test.ts +0 -61
  137. package/src/__tests__/xlsxExport.test.ts +0 -164
  138. package/src/__tests__/xlsxImport.test.ts +0 -80
  139. package/src/__tests__/zipSafety.test.ts +0 -317
  140. package/src/container/index.ts +0 -94
  141. package/src/csv/index.ts +0 -188
  142. package/src/docx/export.ts +0 -1375
  143. package/src/docx/import.ts +0 -1250
  144. package/src/docx/index.ts +0 -26
  145. package/src/docx/styles.ts +0 -145
  146. package/src/epub/export.ts +0 -968
  147. package/src/epub/index.ts +0 -20
  148. package/src/html/docsHtmlBundle.ts +0 -373
  149. package/src/html/htmlTemplate.ts +0 -385
  150. package/src/html/imageUtils.ts +0 -61
  151. package/src/html/import.ts +0 -297
  152. package/src/html/index.ts +0 -212
  153. package/src/html/plainHtml.ts +0 -790
  154. package/src/html/plainHtmlBundle.ts +0 -421
  155. package/src/index.ts +0 -109
  156. package/src/infer/extract.ts +0 -127
  157. package/src/infer/index.ts +0 -199
  158. package/src/infer/mapTheme.ts +0 -176
  159. package/src/infer/types.ts +0 -27
  160. package/src/ooxml/index.ts +0 -111
  161. package/src/ooxml/namespaces.ts +0 -217
  162. package/src/ooxml/readUtils.ts +0 -44
  163. package/src/ooxml/reader.ts +0 -318
  164. package/src/ooxml/themeReader.ts +0 -197
  165. package/src/ooxml/types.ts +0 -103
  166. package/src/ooxml/writer.ts +0 -339
  167. package/src/ooxml/xmlUtils.ts +0 -123
  168. package/src/pdf/export.ts +0 -1084
  169. package/src/pdf/import.ts +0 -1164
  170. package/src/pdf/index.ts +0 -29
  171. package/src/pdf/styles.ts +0 -180
  172. package/src/pptx/export.ts +0 -1184
  173. package/src/pptx/import.ts +0 -455
  174. package/src/pptx/index.ts +0 -52
  175. package/src/pptx/layouts.ts +0 -1222
  176. package/src/pptx/styles.ts +0 -96
  177. package/src/pptx/templates.ts +0 -187
  178. package/src/registry/convert.ts +0 -433
  179. package/src/registry/defaultFormats.ts +0 -413
  180. package/src/registry/errors.ts +0 -46
  181. package/src/registry/index.ts +0 -43
  182. package/src/registry/registry.ts +0 -48
  183. package/src/registry/types.ts +0 -170
  184. package/src/shared/boundedZipArchive.ts +0 -383
  185. package/src/shared/container.ts +0 -28
  186. package/src/shared/fidelity.ts +0 -130
  187. package/src/shared/images.ts +0 -44
  188. package/src/shared/inlineRuns.ts +0 -99
  189. package/src/shared/text.ts +0 -41
  190. package/src/shared/zipEntryCount.ts +0 -151
  191. package/src/shared/zipLimits.ts +0 -296
  192. package/src/shared/zipSafety.ts +0 -19
  193. package/src/xlsx/export.ts +0 -253
  194. package/src/xlsx/import.ts +0 -160
  195. package/src/xlsx/index.ts +0 -35
@@ -1,395 +0,0 @@
1
- /**
2
- * Tests for PPTX layout inference: placeholder extraction (EMU→%, master
3
- * inheritance, chrome exclusion), the built-in-vs-custom classifier, custom
4
- * template generation, importer annotations, and the dialog inspection API.
5
- */
6
-
7
- import { describe, expect, it } from 'vitest';
8
- import type { MarkdownHeading, MarkdownList } from '@bendyline/squisq/markdown';
9
- import { parseMarkdown, stringifyMarkdown } from '@bendyline/squisq/markdown';
10
- import {
11
- expandDocBlocks,
12
- markdownToDoc,
13
- readCustomTemplatesFromFrontmatter,
14
- } from '@bendyline/squisq/doc';
15
- import type { CustomTemplateLayer, TextLayer } from '@bendyline/squisq/schemas';
16
- import { openPackage } from '../ooxml/reader';
17
- import { analyzePptxLayouts, inspectPptxLayouts } from '../pptx/layouts';
18
- import { pptxToContainer, pptxToMarkdownDoc } from '../pptx/import';
19
- import { NS_DRAWINGML, NS_PML, NS_R, REL_IMAGE } from '../ooxml/namespaces';
20
- import { xmlDeclaration } from '../ooxml/xmlUtils';
21
- import { buildThemedPptx, layoutXml, phSp, slideXml } from './pptxInferFixtures';
22
-
23
- // Shared EMU rects (12192000×6858000 slide → easy percentages).
24
- const TITLE_RECT = { x: 914400, y: 685800, cx: 10363200, cy: 1371600 }; // 7.5,10,85,20
25
- const LEFT_COL = { x: 609600, y: 1714500, cx: 5181600, cy: 4114800 }; // 5,25,42.5,60
26
- const RIGHT_COL = { x: 6400800, y: 1714500, cx: 5181600, cy: 4114800 }; // 52.5,25,42.5,60
27
- const PIC_LEFT = { x: 609600, y: 1714500, cx: 4876800, cy: 3429000 }; // 5,25,40,50
28
- const TEXT_RIGHT = { x: 6096000, y: 1714500, cx: 5486400, cy: 3429000 }; // 50,25,45,50
29
-
30
- const twoContentLayout = (name = 'Two Content') =>
31
- layoutXml({
32
- name,
33
- shapes: [
34
- phSp({ type: 'title', rect: TITLE_RECT }, 2),
35
- phSp({ idx: 1, rect: LEFT_COL }, 3),
36
- phSp({ idx: 2, rect: RIGHT_COL }, 4),
37
- ],
38
- });
39
-
40
- describe('analyzePptxLayouts — extraction', () => {
41
- it('converts explicit EMU geometry to percent rects', async () => {
42
- const data = await buildThemedPptx({
43
- layouts: [layoutXml({ name: 'Weird', shapes: [phSp({ type: 'title', rect: TITLE_RECT })] })],
44
- slides: [{ xml: slideXml('T'), layoutIndex: 0 }],
45
- });
46
- const analysis = await analyzePptxLayouts(await openPackage(data));
47
- const layout = analysis.layouts[0]!;
48
- expect(layout.extracted.placeholders).toHaveLength(1);
49
- expect(layout.extracted.placeholders[0]!.rect).toEqual({ x: 7.5, y: 10, w: 85, h: 20 });
50
- expect(layout.extracted.placeholders[0]!.inheritedFromMaster).toBe(false);
51
- expect(layout.extracted.slideCount).toBe(1);
52
- });
53
-
54
- it('inherits geometry from the master by placeholder type', async () => {
55
- const data = await buildThemedPptx({
56
- masterShapes: [phSp({ type: 'title', rect: TITLE_RECT }, 9)],
57
- layouts: [layoutXml({ name: 'Inherits', shapes: [phSp({ type: 'title' })] })],
58
- slides: [{ xml: slideXml('T'), layoutIndex: 0 }],
59
- });
60
- const analysis = await analyzePptxLayouts(await openPackage(data));
61
- const ph = analysis.layouts[0]!.extracted.placeholders[0]!;
62
- expect(ph.inheritedFromMaster).toBe(true);
63
- expect(ph.rect).toEqual({ x: 7.5, y: 10, w: 85, h: 20 });
64
- });
65
-
66
- it('excludes date/footer/slide-number chrome placeholders', async () => {
67
- const data = await buildThemedPptx({
68
- layouts: [
69
- layoutXml({
70
- name: 'Chromed',
71
- shapes: [
72
- phSp({ type: 'title', rect: TITLE_RECT }, 2),
73
- phSp({ type: 'dt', idx: 10, rect: LEFT_COL }, 5),
74
- phSp({ type: 'ftr', idx: 11, rect: RIGHT_COL }, 6),
75
- phSp({ type: 'sldNum', idx: 12, rect: PIC_LEFT }, 7),
76
- ],
77
- }),
78
- ],
79
- slides: [{ xml: slideXml('T'), layoutIndex: 0 }],
80
- });
81
- const analysis = await analyzePptxLayouts(await openPackage(data));
82
- const layout = analysis.layouts[0]!;
83
- expect(layout.extracted.placeholders).toHaveLength(1);
84
- expect(layout.extracted.placeholders[0]!.kind).toBe('title');
85
- // Title-only layout → plain (no template adds value).
86
- expect(layout.verdict.kind).toBe('plain');
87
- });
88
- });
89
-
90
- describe('layout classification → importer annotations', () => {
91
- it('annotates a title-layout slide with {[title subtitle="…"]} and omits the subtitle bullet', async () => {
92
- const data = await buildThemedPptx({
93
- layouts: [
94
- layoutXml({
95
- type: 'title',
96
- name: 'Title Slide',
97
- shapes: [
98
- phSp({ type: 'ctrTitle', rect: TITLE_RECT }, 2),
99
- phSp({ type: 'subTitle', idx: 1, rect: LEFT_COL }, 3),
100
- ],
101
- }),
102
- ],
103
- slides: [
104
- {
105
- xml: slideXml('Deck Title', [{ phType: 'subTitle', phIdx: 1, texts: ['The subtitle'] }]),
106
- layoutIndex: 0,
107
- },
108
- ],
109
- });
110
- const doc = await pptxToMarkdownDoc(data);
111
- const heading = doc.children[0] as MarkdownHeading;
112
- expect(heading.templateAnnotation).toEqual({
113
- template: 'title',
114
- params: { subtitle: 'The subtitle' },
115
- });
116
- // The subtitle moved into the card — no bullet list remains.
117
- expect(doc.children.some((b) => b.type === 'list')).toBe(false);
118
-
119
- // The annotation survives a stringify → parse round-trip.
120
- const reparsed = parseMarkdown(stringifyMarkdown(doc));
121
- const rtHeading = reparsed.children.find((b) => b.type === 'heading') as MarkdownHeading;
122
- expect(rtHeading.templateAnnotation?.template).toBe('title');
123
- expect(rtHeading.templateAnnotation?.params?.subtitle).toBe('The subtitle');
124
- });
125
-
126
- it('generates a custom two-column template for "Two Content" and annotates its slides', async () => {
127
- const data = await buildThemedPptx({
128
- layouts: [twoContentLayout()],
129
- slides: [
130
- {
131
- xml: slideXml('Side by side', [
132
- { phIdx: 1, texts: ['Left one', 'Left two'] },
133
- { phIdx: 2, texts: ['Right one'] },
134
- ]),
135
- layoutIndex: 0,
136
- },
137
- ],
138
- });
139
- const doc = await pptxToMarkdownDoc(data);
140
-
141
- const heading = doc.children[0] as MarkdownHeading;
142
- expect(heading.templateAnnotation?.template).toBe('pptx-two-content');
143
- // Body text still flows as bullets (nothing is lost from the document).
144
- const list = doc.children.find((b) => b.type === 'list') as MarkdownList;
145
- expect(list.children).toHaveLength(3);
146
-
147
- // The definition rides in frontmatter and round-trips through markdown.
148
- const templates = readCustomTemplatesFromFrontmatter(doc.frontmatter);
149
- expect(templates).toHaveLength(1);
150
- const def = templates![0]!;
151
- expect(def.name).toBe('pptx-two-content');
152
- expect(def.label).toBe('Two Content');
153
-
154
- const repeatLayer = def.layers.find((l): l is CustomTemplateLayer & TextLayer =>
155
- Boolean(l.repeat),
156
- );
157
- expect(repeatLayer).toBeDefined();
158
- expect(repeatLayer!.repeat).toEqual({ source: 'listItems', direction: 'row', gap: 5, max: 2 });
159
- expect(repeatLayer!.type).toBe('text');
160
- expect((repeatLayer as TextLayer).content.text).toBe('{item}');
161
- expect((repeatLayer as TextLayer).position.x).toBe('5%');
162
- expect((repeatLayer as TextLayer).position.width).toBe('42.5%');
163
-
164
- // End-to-end: the custom template resolves tokens against the slide content.
165
- const reparsed = parseMarkdown(stringifyMarkdown(doc));
166
- const squisqDoc = markdownToDoc(reparsed);
167
- expect(squisqDoc.customTemplates).toHaveLength(1);
168
- const blocks = expandDocBlocks(squisqDoc.blocks, {
169
- customTemplates: squisqDoc.customTemplates,
170
- });
171
- const block = blocks.find((b) => b.template === 'pptx-two-content');
172
- expect(block).toBeDefined();
173
- const textLayers = (block!.layers ?? []).filter((l): l is TextLayer => l.type === 'text');
174
- expect(textLayers.some((l) => l.content.text === 'Side by side')).toBe(true);
175
- expect(textLayers.some((l) => l.content.text === 'Left one')).toBe(true);
176
- });
177
-
178
- it('maps a picture-beside-text layout to leftFeature with image params (container path)', async () => {
179
- const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 9, 9]);
180
- const slideWithPic =
181
- `${xmlDeclaration()}<p:sld xmlns:p="${NS_PML}" xmlns:a="${NS_DRAWINGML}" xmlns:r="${NS_R}"><p:cSld><p:spTree>` +
182
- `<p:sp><p:nvSpPr><p:nvPr><p:ph type="title"/></p:nvPr></p:nvSpPr>` +
183
- `<p:txBody><a:p><a:r><a:t>Photo slide</a:t></a:r></a:p></p:txBody></p:sp>` +
184
- `<p:sp><p:nvSpPr><p:nvPr><p:ph idx="2"/></p:nvPr></p:nvSpPr>` +
185
- `<p:txBody><a:p><a:r><a:t>Beside text</a:t></a:r></a:p></p:txBody></p:sp>` +
186
- `<p:pic><p:nvPicPr><p:cNvPr id="9" name="Photo" descr="A nice photo"/></p:nvPicPr>` +
187
- `<p:blipFill><a:blip r:embed="rIdImg"/></p:blipFill></p:pic>` +
188
- `</p:spTree></p:cSld></p:sld>`;
189
- const build = () =>
190
- buildThemedPptx({
191
- layouts: [
192
- layoutXml({
193
- type: 'cust',
194
- name: 'Photo Left',
195
- shapes: [
196
- phSp({ type: 'title', rect: TITLE_RECT }, 2),
197
- phSp({ type: 'pic', idx: 1, rect: PIC_LEFT }, 3),
198
- phSp({ idx: 2, rect: TEXT_RIGHT }, 4),
199
- ],
200
- }),
201
- ],
202
- slides: [
203
- {
204
- xml: slideWithPic,
205
- layoutIndex: 0,
206
- rels: [{ id: 'rIdImg', type: REL_IMAGE, target: '../media/photo.png' }],
207
- media: [{ path: 'ppt/media/photo.png', data: pngBytes, contentType: 'image/png' }],
208
- },
209
- ],
210
- });
211
-
212
- // With extracted images (container path) → annotated with the image param.
213
- const container = await pptxToContainer(await build());
214
- const markdown = (await container.readDocument())!;
215
- const parsed = parseMarkdown(markdown);
216
- const heading = parsed.children.find((b) => b.type === 'heading') as MarkdownHeading;
217
- expect(heading.templateAnnotation?.template).toBe('leftFeature');
218
- expect(heading.templateAnnotation?.params?.imageSrc).toBe('images/image1.png');
219
- expect(heading.templateAnnotation?.params?.imageAlt).toBe('A nice photo');
220
-
221
- // Without extractImages → the feature verdict downgrades to plain.
222
- const plainDoc = await pptxToMarkdownDoc(await build());
223
- const plainHeading = plainDoc.children[0] as MarkdownHeading;
224
- expect(plainHeading.templateAnnotation).toBeUndefined();
225
- });
226
-
227
- it('skips blank layouts and leaves title-and-content plain', async () => {
228
- const data = await buildThemedPptx({
229
- layouts: [
230
- layoutXml({ type: 'blank', name: 'Blank', shapes: [] }),
231
- layoutXml({
232
- type: 'obj',
233
- name: 'Title and Content',
234
- shapes: [
235
- phSp({ type: 'title', rect: TITLE_RECT }, 2),
236
- phSp({ idx: 1, rect: LEFT_COL }, 3),
237
- ],
238
- }),
239
- ],
240
- slides: [
241
- { xml: slideXml('On blank', [{ texts: ['stray text'] }]), layoutIndex: 0 },
242
- { xml: slideXml('Normal', [{ phIdx: 1, texts: ['a bullet'] }]), layoutIndex: 1 },
243
- ],
244
- });
245
- const doc = await pptxToMarkdownDoc(data);
246
- const headings = doc.children.filter((b) => b.type === 'heading') as MarkdownHeading[];
247
- expect(headings[0]!.templateAnnotation).toBeUndefined();
248
- expect(headings[1]!.templateAnnotation).toBeUndefined();
249
- // Content still converts on both slides.
250
- expect(doc.children.filter((b) => b.type === 'list')).toHaveLength(2);
251
- // No custom templates were generated → no frontmatter payload.
252
- expect(doc.frontmatter?.['squisq-custom-templates']).toBeUndefined();
253
- });
254
-
255
- it('deduplicates: two slides on one layout share a single definition', async () => {
256
- const data = await buildThemedPptx({
257
- layouts: [twoContentLayout()],
258
- slides: [
259
- { xml: slideXml('One', [{ phIdx: 1, texts: ['a'] }]), layoutIndex: 0 },
260
- { xml: slideXml('Two', [{ phIdx: 1, texts: ['b'] }]), layoutIndex: 0 },
261
- ],
262
- });
263
- const doc = await pptxToMarkdownDoc(data);
264
- const headings = doc.children.filter((b) => b.type === 'heading') as MarkdownHeading[];
265
- expect(headings[0]!.templateAnnotation?.template).toBe('pptx-two-content');
266
- expect(headings[1]!.templateAnnotation?.template).toBe('pptx-two-content');
267
- expect(readCustomTemplatesFromFrontmatter(doc.frontmatter)).toHaveLength(1);
268
- });
269
-
270
- it('builds twoColumn comparison params from idx-matched texts, downgrading when headers are missing', async () => {
271
- const HEADER_L = { x: 609600, y: 1371600, cx: 5181600, cy: 685800 }; // 5,20,42.5,10
272
- const HEADER_R = { x: 6400800, y: 1371600, cx: 5181600, cy: 685800 };
273
- const BODY_L = { x: 609600, y: 2286000, cx: 5181600, cy: 3429000 }; // 5,33.33,42.5,50
274
- const BODY_R = { x: 6400800, y: 2286000, cx: 5181600, cy: 3429000 };
275
- const comparisonLayout = layoutXml({
276
- type: 'twoTxTwoObj',
277
- name: 'Comparison',
278
- shapes: [
279
- phSp({ type: 'title', rect: TITLE_RECT }, 2),
280
- phSp({ type: 'body', idx: 1, rect: HEADER_L }, 3),
281
- phSp({ type: 'body', idx: 2, rect: HEADER_R }, 4),
282
- phSp({ idx: 3, rect: BODY_L }, 5),
283
- phSp({ idx: 4, rect: BODY_R }, 6),
284
- ],
285
- });
286
- const data = await buildThemedPptx({
287
- layouts: [comparisonLayout],
288
- slides: [
289
- {
290
- xml: slideXml('Compare', [
291
- { phType: 'body', phIdx: 1, texts: ['Alpha'] },
292
- { phType: 'body', phIdx: 2, texts: ['Beta'] },
293
- { phIdx: 3, texts: ['fast'] },
294
- { phIdx: 4, texts: ['steady'] },
295
- ]),
296
- layoutIndex: 0,
297
- },
298
- {
299
- // Missing the right header text → no annotation.
300
- xml: slideXml('Half compare', [{ phType: 'body', phIdx: 1, texts: ['Alpha'] }]),
301
- layoutIndex: 0,
302
- },
303
- ],
304
- });
305
- const doc = await pptxToMarkdownDoc(data);
306
- const headings = doc.children.filter((b) => b.type === 'heading') as MarkdownHeading[];
307
- expect(headings[0]!.templateAnnotation).toEqual({
308
- template: 'twoColumn',
309
- params: { left: 'Alpha|fast', right: 'Beta|steady' },
310
- });
311
- expect(headings[1]!.templateAnnotation).toBeUndefined();
312
- });
313
-
314
- it('caps generated templates at maxTemplates, keeping the most-used layouts', async () => {
315
- const data = await buildThemedPptx({
316
- layouts: [twoContentLayout('Grid A'), twoContentLayout('Grid B'), twoContentLayout('Grid C')],
317
- slides: [
318
- { xml: slideXml('a1', [{ phIdx: 1, texts: ['x'] }]), layoutIndex: 0 },
319
- { xml: slideXml('a2', [{ phIdx: 1, texts: ['x'] }]), layoutIndex: 0 },
320
- { xml: slideXml('b1', [{ phIdx: 1, texts: ['x'] }]), layoutIndex: 1 },
321
- { xml: slideXml('b2', [{ phIdx: 1, texts: ['x'] }]), layoutIndex: 1 },
322
- { xml: slideXml('c1', [{ phIdx: 1, texts: ['x'] }]), layoutIndex: 2 },
323
- ],
324
- });
325
- const analysis = await analyzePptxLayouts(await openPackage(data), { maxTemplates: 2 });
326
- const verdicts = analysis.layouts.map((l) => l.verdict.kind);
327
- expect(verdicts.filter((k) => k === 'custom')).toHaveLength(2);
328
- // The least-used layout (Grid C, 1 slide) was downgraded.
329
- const gridC = analysis.layouts.find((l) => l.extracted.name === 'Grid C')!;
330
- expect(gridC.verdict.kind).toBe('plain');
331
- expect(analysis.warnings.some((w) => w.includes('kept the 2 most used'))).toBe(true);
332
- });
333
-
334
- it('uses a portrait viewport for portrait decks', async () => {
335
- const data = await buildThemedPptx({
336
- sldSz: { cx: 6858000, cy: 12192000 },
337
- layouts: [
338
- layoutXml({
339
- name: 'Tall Two',
340
- shapes: [
341
- phSp({ type: 'title', rect: { x: 342900, y: 609600, cx: 6172200, cy: 1219200 } }, 2),
342
- phSp({ idx: 1, rect: { x: 342900, y: 2438400, cx: 2743200, cy: 7315200 } }, 3),
343
- phSp({ idx: 2, rect: { x: 3771900, y: 2438400, cx: 2743200, cy: 7315200 } }, 4),
344
- ],
345
- }),
346
- ],
347
- slides: [{ xml: slideXml('Tall', [{ phIdx: 1, texts: ['x'] }]), layoutIndex: 0 }],
348
- });
349
- const analysis = await analyzePptxLayouts(await openPackage(data));
350
- const verdict = analysis.layouts[0]!.verdict;
351
- expect(verdict.kind).toBe('custom');
352
- if (verdict.kind === 'custom') {
353
- expect(verdict.def.viewport).toEqual({ width: 1080, height: 1920 });
354
- }
355
- });
356
- });
357
-
358
- describe('inspectPptxLayouts', () => {
359
- it('summarizes verdicts, includes built-in matches, and can list unused layouts', async () => {
360
- const data = await buildThemedPptx({
361
- layouts: [
362
- layoutXml({
363
- type: 'title',
364
- name: 'Title Slide',
365
- shapes: [phSp({ type: 'ctrTitle', rect: TITLE_RECT }, 2)],
366
- }),
367
- twoContentLayout(),
368
- layoutXml({ type: 'blank', name: 'Blank', shapes: [] }),
369
- ],
370
- slides: [
371
- { xml: slideXml('T'), layoutIndex: 0 },
372
- { xml: slideXml('S', [{ phIdx: 1, texts: ['x'] }]), layoutIndex: 1 },
373
- ],
374
- });
375
-
376
- const usedOnly = await inspectPptxLayouts(data);
377
- expect(usedOnly.layouts).toHaveLength(2);
378
- expect(usedOnly.slideSize).toEqual({ cx: 12192000, cy: 6858000 });
379
-
380
- const titleRow = usedOnly.layouts.find((l) => l.name === 'Title Slide')!;
381
- expect(titleRow.verdict).toBe('builtin');
382
- expect(titleRow.builtinTemplate).toBe('title');
383
- expect(titleRow.slideCount).toBe(1);
384
-
385
- const twoRow = usedOnly.layouts.find((l) => l.name === 'Two Content')!;
386
- expect(twoRow.verdict).toBe('custom');
387
- expect(twoRow.customTemplate?.name).toBe('pptx-two-content');
388
-
389
- const all = await inspectPptxLayouts(data, { includeUnused: true });
390
- expect(all.layouts).toHaveLength(3);
391
- const blankRow = all.layouts.find((l) => l.name === 'Blank')!;
392
- expect(blankRow.verdict).toBe('skip');
393
- expect(blankRow.slideCount).toBe(0);
394
- });
395
- });
@@ -1,201 +0,0 @@
1
- /**
2
- * Round-trip tests: MarkdownDocument → DOCX → MarkdownDocument.
3
- *
4
- * Verifies that core document structures survive the export → import cycle.
5
- */
6
-
7
- import { describe, it, expect } from 'vitest';
8
- import type {
9
- MarkdownDocument,
10
- MarkdownHeading,
11
- MarkdownParagraph,
12
- MarkdownStrong,
13
- MarkdownEmphasis,
14
- MarkdownInlineNode,
15
- MarkdownBlockNode,
16
- } from '@bendyline/squisq/markdown';
17
-
18
- import { markdownDocToDocx } from '../docx/export';
19
- import { docxToMarkdownDoc } from '../docx/import';
20
-
21
- // ============================================
22
- // Helpers
23
- // ============================================
24
-
25
- async function roundTrip(doc: MarkdownDocument): Promise<MarkdownDocument> {
26
- const buffer = await markdownDocToDocx(doc);
27
- return docxToMarkdownDoc(buffer);
28
- }
29
-
30
- /**
31
- * Extract plain text from an inline node tree.
32
- */
33
- function extractText(node: MarkdownInlineNode): string {
34
- if (node.type === 'text') return node.value;
35
- if ('children' in node) return (node.children as MarkdownInlineNode[]).map(extractText).join('');
36
- if ('value' in node) return node.value as string;
37
- return '';
38
- }
39
-
40
- function extractBlockText(block: MarkdownBlockNode): string {
41
- if ('children' in block)
42
- return (block.children as MarkdownInlineNode[]).map(extractText).join('');
43
- if ('value' in block) return block.value as string;
44
- return '';
45
- }
46
-
47
- // ============================================
48
- // Round-trip Tests
49
- // ============================================
50
-
51
- describe('DOCX round-trip', () => {
52
- it('round-trips headings', async () => {
53
- const original: MarkdownDocument = {
54
- type: 'document',
55
- children: [
56
- {
57
- type: 'heading',
58
- depth: 1,
59
- children: [{ type: 'text', value: 'Main Title' }],
60
- } satisfies MarkdownHeading,
61
- {
62
- type: 'heading',
63
- depth: 2,
64
- children: [{ type: 'text', value: 'Section' }],
65
- } satisfies MarkdownHeading,
66
- ],
67
- };
68
-
69
- const result = await roundTrip(original);
70
-
71
- expect(result.children.length).toBeGreaterThanOrEqual(2);
72
- expect(result.children[0].type).toBe('heading');
73
- expect((result.children[0] as MarkdownHeading).depth).toBe(1);
74
- expect(extractBlockText(result.children[0])).toBe('Main Title');
75
-
76
- expect(result.children[1].type).toBe('heading');
77
- expect((result.children[1] as MarkdownHeading).depth).toBe(2);
78
- expect(extractBlockText(result.children[1])).toBe('Section');
79
- });
80
-
81
- it('round-trips paragraphs', async () => {
82
- const original: MarkdownDocument = {
83
- type: 'document',
84
- children: [
85
- {
86
- type: 'paragraph',
87
- children: [{ type: 'text', value: 'Hello world' }],
88
- } satisfies MarkdownParagraph,
89
- {
90
- type: 'paragraph',
91
- children: [{ type: 'text', value: 'Second paragraph' }],
92
- } satisfies MarkdownParagraph,
93
- ],
94
- };
95
-
96
- const result = await roundTrip(original);
97
-
98
- expect(result.children.length).toBe(2);
99
- expect(extractBlockText(result.children[0])).toBe('Hello world');
100
- expect(extractBlockText(result.children[1])).toBe('Second paragraph');
101
- });
102
-
103
- it('round-trips bold text', async () => {
104
- const original: MarkdownDocument = {
105
- type: 'document',
106
- children: [
107
- {
108
- type: 'paragraph',
109
- children: [
110
- { type: 'text', value: 'Normal ' },
111
- {
112
- type: 'strong',
113
- children: [{ type: 'text', value: 'bold' }],
114
- } satisfies MarkdownStrong,
115
- { type: 'text', value: ' text' },
116
- ],
117
- },
118
- ],
119
- };
120
-
121
- const result = await roundTrip(original);
122
- const para = result.children[0] as MarkdownParagraph;
123
- expect(para.type).toBe('paragraph');
124
-
125
- // Find the bold node
126
- const boldNode = para.children.find((c: MarkdownInlineNode) => c.type === 'strong');
127
- expect(boldNode).toBeDefined();
128
- expect(extractText(boldNode!)).toBe('bold');
129
- });
130
-
131
- it('round-trips italic text', async () => {
132
- const original: MarkdownDocument = {
133
- type: 'document',
134
- children: [
135
- {
136
- type: 'paragraph',
137
- children: [
138
- {
139
- type: 'emphasis',
140
- children: [{ type: 'text', value: 'italic' }],
141
- } satisfies MarkdownEmphasis,
142
- ],
143
- },
144
- ],
145
- };
146
-
147
- const result = await roundTrip(original);
148
- const para = result.children[0] as MarkdownParagraph;
149
- const italicNode = para.children.find((c: MarkdownInlineNode) => c.type === 'emphasis');
150
- expect(italicNode).toBeDefined();
151
- expect(extractText(italicNode!)).toBe('italic');
152
- });
153
-
154
- it('round-trips mixed content document', async () => {
155
- const original: MarkdownDocument = {
156
- type: 'document',
157
- children: [
158
- {
159
- type: 'heading',
160
- depth: 1,
161
- children: [{ type: 'text', value: 'Document Title' }],
162
- } satisfies MarkdownHeading,
163
- {
164
- type: 'paragraph',
165
- children: [
166
- { type: 'text', value: 'This is ' },
167
- {
168
- type: 'strong',
169
- children: [{ type: 'text', value: 'important' }],
170
- },
171
- { type: 'text', value: ' content.' },
172
- ],
173
- },
174
- {
175
- type: 'heading',
176
- depth: 2,
177
- children: [{ type: 'text', value: 'Details' }],
178
- } satisfies MarkdownHeading,
179
- {
180
- type: 'paragraph',
181
- children: [{ type: 'text', value: 'More info here.' }],
182
- },
183
- ],
184
- };
185
-
186
- const result = await roundTrip(original);
187
-
188
- // Should have at least 4 blocks
189
- expect(result.children.length).toBeGreaterThanOrEqual(4);
190
-
191
- // First should be heading
192
- expect(result.children[0].type).toBe('heading');
193
- expect(extractBlockText(result.children[0])).toBe('Document Title');
194
-
195
- // Then paragraph with mixed content
196
- expect(result.children[1].type).toBe('paragraph');
197
- const fullText = (result.children[1] as MarkdownParagraph).children.map(extractText).join('');
198
- expect(fullText).toContain('important');
199
- expect(fullText).toContain('content');
200
- });
201
- });
@@ -1,50 +0,0 @@
1
- /**
2
- * Asset-fidelity guard: an embedded image must survive a full export → import
3
- * round-trip for every container-producing format. We author a markdown doc
4
- * with an image, export it (supplying the image bytes), re-import via the
5
- * format's `*ToContainer`, and assert the bytes + reference come back intact.
6
- */
7
-
8
- import { describe, expect, it } from 'vitest';
9
- import type { MarkdownDocument } from '@bendyline/squisq/markdown';
10
- import { markdownDocToPptx } from '../pptx/export';
11
- import { pptxToContainer } from '../pptx/import';
12
- import { markdownDocToDocx } from '../docx/export';
13
- import { docxToContainer } from '../docx/import';
14
-
15
- /** PNG-signature-prefixed payload with a distinctive tail for byte comparison. */
16
- const IMAGE_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 9, 8, 7, 6]);
17
-
18
- const doc: MarkdownDocument = {
19
- type: 'document',
20
- children: [
21
- { type: 'heading', depth: 1, children: [{ type: 'text', value: 'Assets' }] },
22
- { type: 'paragraph', children: [{ type: 'image', url: 'hero.png', alt: 'Hero' }] },
23
- ],
24
- };
25
-
26
- describe('embedded image round-trips through container formats', () => {
27
- it('survives markdown → pptx → pptxToContainer', async () => {
28
- const pptx = await markdownDocToPptx(doc, {
29
- images: new Map([['hero.png', IMAGE_BYTES.buffer]]),
30
- });
31
- const container = await pptxToContainer(pptx);
32
-
33
- expect(await container.exists('images/image1.png')).toBe(true);
34
- const bytes = await container.readFile('images/image1.png');
35
- expect(new Uint8Array(bytes!)).toEqual(IMAGE_BYTES);
36
- expect(await container.readDocument()).toContain('images/image1.png');
37
- });
38
-
39
- it('survives markdown → docx → docxToContainer', async () => {
40
- const docx = await markdownDocToDocx(doc, {
41
- images: new Map([['hero.png', { data: IMAGE_BYTES.buffer, contentType: 'image/png' }]]),
42
- });
43
- const container = await docxToContainer(docx);
44
-
45
- expect(await container.exists('images/image1.png')).toBe(true);
46
- const bytes = await container.readFile('images/image1.png');
47
- expect(new Uint8Array(bytes!)).toEqual(IMAGE_BYTES);
48
- expect(await container.readDocument()).toContain('images/image1.png');
49
- });
50
- });