@bendyline/squisq-formats 1.1.0 → 1.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 (89) hide show
  1. package/README.md +23 -10
  2. package/dist/{chunk-ULLIPBEJ.js → chunk-743COJWQ.js} +6 -2
  3. package/dist/chunk-743COJWQ.js.map +1 -0
  4. package/dist/{chunk-KAK4V57E.js → chunk-BHHEDPRB.js} +16 -112
  5. package/dist/chunk-BHHEDPRB.js.map +1 -0
  6. package/dist/{chunk-MQHCXI56.js → chunk-CLGUOVYR.js} +193 -23
  7. package/dist/chunk-CLGUOVYR.js.map +1 -0
  8. package/dist/chunk-FIFCQN3W.js +630 -0
  9. package/dist/chunk-FIFCQN3W.js.map +1 -0
  10. package/dist/chunk-KJ4NS4DX.js +120 -0
  11. package/dist/chunk-KJ4NS4DX.js.map +1 -0
  12. package/dist/{chunk-TBPD5PCU.js → chunk-S3Y7H2BK.js} +161 -11
  13. package/dist/chunk-S3Y7H2BK.js.map +1 -0
  14. package/dist/docx/export.d.ts +15 -0
  15. package/dist/docx/export.d.ts.map +1 -1
  16. package/dist/docx/export.js +144 -19
  17. package/dist/docx/export.js.map +1 -1
  18. package/dist/docx/import.d.ts +13 -0
  19. package/dist/docx/import.d.ts.map +1 -1
  20. package/dist/docx/import.js +102 -10
  21. package/dist/docx/import.js.map +1 -1
  22. package/dist/docx/index.d.ts +1 -1
  23. package/dist/docx/index.d.ts.map +1 -1
  24. package/dist/docx/index.js +1 -1
  25. package/dist/docx/index.js.map +1 -1
  26. package/dist/html/htmlTemplate.d.ts +6 -0
  27. package/dist/html/htmlTemplate.d.ts.map +1 -1
  28. package/dist/html/htmlTemplate.js +5 -1
  29. package/dist/html/htmlTemplate.js.map +1 -1
  30. package/dist/index.d.ts +1 -1
  31. package/dist/index.js +2 -2
  32. package/dist/index.js.map +1 -1
  33. package/dist/ooxml/index.d.ts +1 -1
  34. package/dist/ooxml/index.d.ts.map +1 -1
  35. package/dist/ooxml/index.js +1 -1
  36. package/dist/ooxml/index.js.map +1 -1
  37. package/dist/ooxml/namespaces.d.ts +10 -0
  38. package/dist/ooxml/namespaces.d.ts.map +1 -1
  39. package/dist/ooxml/namespaces.js +10 -0
  40. package/dist/ooxml/namespaces.js.map +1 -1
  41. package/dist/pdf/export.d.ts +6 -0
  42. package/dist/pdf/export.d.ts.map +1 -1
  43. package/dist/pdf/export.js +37 -9
  44. package/dist/pdf/export.js.map +1 -1
  45. package/dist/pdf/import.d.ts +16 -0
  46. package/dist/pdf/import.d.ts.map +1 -1
  47. package/dist/pdf/import.js +177 -0
  48. package/dist/pdf/import.js.map +1 -1
  49. package/dist/pdf/index.d.ts +1 -1
  50. package/dist/pdf/index.d.ts.map +1 -1
  51. package/dist/pdf/index.js +1 -1
  52. package/dist/pdf/index.js.map +1 -1
  53. package/dist/pptx/export.d.ts +75 -0
  54. package/dist/pptx/export.d.ts.map +1 -0
  55. package/dist/pptx/export.js +722 -0
  56. package/dist/pptx/export.js.map +1 -0
  57. package/dist/pptx/index.d.ts +12 -27
  58. package/dist/pptx/index.d.ts.map +1 -1
  59. package/dist/pptx/index.js +12 -22
  60. package/dist/pptx/index.js.map +1 -1
  61. package/dist/pptx/styles.d.ts +54 -0
  62. package/dist/pptx/styles.d.ts.map +1 -0
  63. package/dist/pptx/styles.js +73 -0
  64. package/dist/pptx/styles.js.map +1 -0
  65. package/dist/pptx/templates.d.ts +24 -0
  66. package/dist/pptx/templates.d.ts.map +1 -0
  67. package/dist/pptx/templates.js +158 -0
  68. package/dist/pptx/templates.js.map +1 -0
  69. package/package.json +2 -2
  70. package/src/docx/export.ts +183 -20
  71. package/src/docx/import.ts +127 -10
  72. package/src/docx/index.ts +1 -1
  73. package/src/html/htmlTemplate.ts +13 -0
  74. package/src/index.ts +2 -2
  75. package/src/ooxml/index.ts +7 -0
  76. package/src/ooxml/namespaces.ts +23 -0
  77. package/src/pdf/export.ts +57 -9
  78. package/src/pdf/import.ts +228 -0
  79. package/src/pdf/index.ts +1 -1
  80. package/src/pptx/export.ts +1041 -0
  81. package/src/pptx/index.ts +15 -37
  82. package/src/pptx/styles.ts +96 -0
  83. package/src/pptx/templates.ts +187 -0
  84. package/dist/chunk-532L4D5D.js +0 -21
  85. package/dist/chunk-532L4D5D.js.map +0 -1
  86. package/dist/chunk-KAK4V57E.js.map +0 -1
  87. package/dist/chunk-MQHCXI56.js.map +0 -1
  88. package/dist/chunk-TBPD5PCU.js.map +0 -1
  89. package/dist/chunk-ULLIPBEJ.js.map +0 -1
@@ -17,7 +17,8 @@
17
17
  * ```
18
18
  */
19
19
 
20
- import type { Doc } from '@bendyline/squisq/schemas';
20
+ import type { Doc, Theme } from '@bendyline/squisq/schemas';
21
+ import { resolveTheme } from '@bendyline/squisq/schemas';
21
22
  import { docToMarkdown } from '@bendyline/squisq/doc';
22
23
  import type {
23
24
  MarkdownDocument,
@@ -52,6 +53,7 @@ import {
52
53
  REL_SETTINGS,
53
54
  REL_FONT_TABLE,
54
55
  REL_HYPERLINK,
56
+ REL_IMAGE,
55
57
  REL_FOOTNOTES,
56
58
  CONTENT_TYPE_DOCX_DOCUMENT,
57
59
  CONTENT_TYPE_DOCX_STYLES,
@@ -90,6 +92,18 @@ export interface DocxExportOptions {
90
92
  defaultFont?: string;
91
93
  /** Default body font size in points. Default: 11 */
92
94
  defaultFontSize?: number;
95
+ /**
96
+ * Squisq theme ID to apply (e.g., 'documentary', 'cinematic').
97
+ * When set, overrides fonts with the theme's typography and applies
98
+ * the theme's primary color to headings.
99
+ */
100
+ themeId?: string;
101
+ /**
102
+ * Pre-resolved image data keyed by image URL/path as it appears in the
103
+ * markdown source. When provided, images are embedded in the .docx file
104
+ * as binary parts instead of emitting placeholder text.
105
+ */
106
+ images?: Map<string, { data: ArrayBuffer | Uint8Array; contentType: string }>;
93
107
  }
94
108
 
95
109
  /**
@@ -167,13 +181,38 @@ class ExportContext {
167
181
  hasFootnotes = false;
168
182
 
169
183
  readonly font: string;
184
+ readonly headingFont: string;
170
185
  readonly fontSize: number;
186
+ /** Heading text color (hex without #), or undefined for default */
187
+ readonly headingColor: string | undefined;
188
+
189
+ /** Pre-resolved image data keyed by markdown image URL */
190
+ readonly resolvedImages: Map<string, { data: ArrayBuffer | Uint8Array; contentType: string }>;
191
+
192
+ private nextDocPrId = 1;
171
193
 
172
194
  constructor(options: DocxExportOptions) {
173
- this.font = options.defaultFont ?? DEFAULT_FONT;
195
+ let themeFont: string | undefined;
196
+ let themeTitleFont: string | undefined;
197
+ let themeHeadingColor: string | undefined;
198
+
199
+ if (options.themeId) {
200
+ const theme: Theme = resolveTheme(options.themeId);
201
+ themeFont = theme.typography?.bodyFontFamily;
202
+ themeTitleFont = theme.typography?.titleFontFamily;
203
+ if (theme.colors?.primary) {
204
+ const c = theme.colors.primary;
205
+ themeHeadingColor = c.startsWith('#') ? c.slice(1) : c;
206
+ }
207
+ }
208
+
209
+ this.font = options.defaultFont ?? themeFont ?? DEFAULT_FONT;
210
+ this.headingFont = themeTitleFont ?? this.font;
174
211
  this.fontSize = options.defaultFontSize
175
212
  ? options.defaultFontSize * 2
176
213
  : DEFAULT_FONT_SIZE_HALF_POINTS;
214
+ this.headingColor = themeHeadingColor;
215
+ this.resolvedImages = options.images ?? new Map();
177
216
  }
178
217
 
179
218
  /** Allocate a new relationship ID */
@@ -193,6 +232,26 @@ class ExportContext {
193
232
  return id;
194
233
  }
195
234
 
235
+ /** Add an embedded image and return the rId and docPrId */
236
+ addImage(
237
+ data: ArrayBuffer | Uint8Array,
238
+ contentType: string,
239
+ filename: string,
240
+ ): { relId: string; docPrId: number } {
241
+ const relId = this.allocRelId();
242
+ const docPrId = this.nextDocPrId++;
243
+ const path = `word/media/${filename}`;
244
+
245
+ this.images.push({ relId, path, data, contentType });
246
+ this.relationships.push({
247
+ id: relId,
248
+ type: REL_IMAGE,
249
+ target: `media/${filename}`,
250
+ });
251
+
252
+ return { relId, docPrId };
253
+ }
254
+
196
255
  /** Allocate a numbering definition for a list */
197
256
  allocNumbering(ordered: boolean): number {
198
257
  const numId = this.nextNumId++;
@@ -509,7 +568,7 @@ function convertInline(node: MarkdownInlineNode, ctx: ExportContext, format: Inl
509
568
  case 'link':
510
569
  return convertLink(node, ctx, format);
511
570
  case 'image':
512
- return convertImage(node);
571
+ return convertImage(node, ctx);
513
572
  case 'break':
514
573
  return `<w:r><w:br/></w:r>`;
515
574
  case 'htmlInline':
@@ -546,10 +605,6 @@ function makeRun(text: string, format: InlineFormat): string {
546
605
 
547
606
  function convertLink(node: MarkdownLink, ctx: ExportContext, format: InlineFormat): string {
548
607
  const rId = ctx.addHyperlink(node.url);
549
- const _runs = convertInlines(node.children, ctx, { ...format });
550
-
551
- // Wrap each run's rPr with hyperlink styling
552
- // For simplicity, emit inline runs with hyperlink color + underline
553
608
  const styledRuns = convertInlinesWithHyperlinkStyle(node.children, ctx, format);
554
609
 
555
610
  return `<w:hyperlink r:id="${rId}">${styledRuns}</w:hyperlink>`;
@@ -589,13 +644,119 @@ function makeHyperlinkRun(text: string, format: InlineFormat): string {
589
644
  );
590
645
  }
591
646
 
592
- function convertImage(_node: MarkdownImage): string {
593
- // Image embedding requires fetching the image data and packing it as
594
- // a binary part. For now, emit a placeholder text run.
595
- // Full image support can be added by fetching the URL and creating
596
- // a DrawingML inline + binary media part.
597
- const alt = _node.alt || _node.url;
598
- return makeRun(`[Image: ${alt}]`, { italic: true });
647
+ function convertImage(node: MarkdownImage, ctx: ExportContext): string {
648
+ const imageEntry = ctx.resolvedImages.get(node.url);
649
+ if (!imageEntry) {
650
+ // No resolved data emit placeholder text
651
+ const alt = node.alt || node.url;
652
+ return makeRun(`[Image: ${alt}]`, { italic: true });
653
+ }
654
+
655
+ const { data, contentType } = imageEntry;
656
+ const ext = contentType.split('/')[1]?.replace('jpeg', 'jpg') || 'png';
657
+ const filename = `image${ctx.images.length + 1}.${ext}`;
658
+ const { relId, docPrId } = ctx.addImage(data, contentType, filename);
659
+
660
+ // Read dimensions from binary header; fall back to 5×3 inches
661
+ const dims = readImageDimensions(data);
662
+ const EMU_PER_INCH = 914400;
663
+ const MAX_WIDTH_EMU = 6 * EMU_PER_INCH; // 6 inch content width
664
+ let cx: number;
665
+ let cy: number;
666
+
667
+ if (dims) {
668
+ // Scale to fit within max width, assuming 96 DPI for pixel → inch
669
+ const widthEmu = (dims.width / 96) * EMU_PER_INCH;
670
+ const heightEmu = (dims.height / 96) * EMU_PER_INCH;
671
+ if (widthEmu > MAX_WIDTH_EMU) {
672
+ const scale = MAX_WIDTH_EMU / widthEmu;
673
+ cx = MAX_WIDTH_EMU;
674
+ cy = Math.round(heightEmu * scale);
675
+ } else {
676
+ cx = Math.round(widthEmu);
677
+ cy = Math.round(heightEmu);
678
+ }
679
+ } else {
680
+ cx = 5 * EMU_PER_INCH;
681
+ cy = 3 * EMU_PER_INCH;
682
+ }
683
+
684
+ const name = escapeXml(node.alt || filename);
685
+ const NS_A = 'http://schemas.openxmlformats.org/drawingml/2006/main';
686
+ const NS_PIC = 'http://schemas.openxmlformats.org/drawingml/2006/picture';
687
+
688
+ return (
689
+ `<w:r><w:drawing>` +
690
+ `<wp:inline distT="0" distB="0" distL="0" distR="0">` +
691
+ `<wp:extent cx="${cx}" cy="${cy}"/>` +
692
+ `<wp:docPr id="${docPrId}" name="${name}"/>` +
693
+ `<wp:cNvGraphicFramePr>` +
694
+ `<a:graphicFrameLocks xmlns:a="${NS_A}" noChangeAspect="1"/>` +
695
+ `</wp:cNvGraphicFramePr>` +
696
+ `<a:graphic xmlns:a="${NS_A}">` +
697
+ `<a:graphicData uri="${NS_PIC}">` +
698
+ `<pic:pic xmlns:pic="${NS_PIC}">` +
699
+ `<pic:nvPicPr>` +
700
+ `<pic:cNvPr id="0" name="${name}"/>` +
701
+ `<pic:cNvPicPr/>` +
702
+ `</pic:nvPicPr>` +
703
+ `<pic:blipFill>` +
704
+ `<a:blip r:embed="${relId}"/>` +
705
+ `<a:stretch><a:fillRect/></a:stretch>` +
706
+ `</pic:blipFill>` +
707
+ `<pic:spPr>` +
708
+ `<a:xfrm>` +
709
+ `<a:off x="0" y="0"/>` +
710
+ `<a:ext cx="${cx}" cy="${cy}"/>` +
711
+ `</a:xfrm>` +
712
+ `<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>` +
713
+ `</pic:spPr>` +
714
+ `</pic:pic>` +
715
+ `</a:graphicData>` +
716
+ `</a:graphic>` +
717
+ `</wp:inline>` +
718
+ `</w:drawing></w:r>`
719
+ );
720
+ }
721
+
722
+ /** Read width/height from PNG or JPEG binary headers. */
723
+ function readImageDimensions(
724
+ data: ArrayBuffer | Uint8Array,
725
+ ): { width: number; height: number } | null {
726
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
727
+ if (bytes.length < 24) return null;
728
+
729
+ // PNG: signature 0x89504E47, IHDR chunk at byte 16
730
+ if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
731
+ const width = (bytes[16] << 24) | (bytes[17] << 16) | (bytes[18] << 8) | bytes[19];
732
+ const height = (bytes[20] << 24) | (bytes[21] << 16) | (bytes[22] << 8) | bytes[23];
733
+ return { width, height };
734
+ }
735
+
736
+ // JPEG: search for SOF0 (0xFFC0) or SOF2 (0xFFC2) marker
737
+ if (bytes[0] === 0xff && bytes[1] === 0xd8) {
738
+ let offset = 2;
739
+ while (offset < bytes.length - 9) {
740
+ if (bytes[offset] !== 0xff) break;
741
+ const marker = bytes[offset + 1];
742
+ if (marker === 0xc0 || marker === 0xc2) {
743
+ const height = (bytes[offset + 5] << 8) | bytes[offset + 6];
744
+ const width = (bytes[offset + 7] << 8) | bytes[offset + 8];
745
+ return { width, height };
746
+ }
747
+ const segLen = (bytes[offset + 2] << 8) | bytes[offset + 3];
748
+ offset += 2 + segLen;
749
+ }
750
+ }
751
+
752
+ // GIF: width at bytes 6-7, height at bytes 8-9 (little-endian)
753
+ if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) {
754
+ const width = bytes[6] | (bytes[7] << 8);
755
+ const height = bytes[8] | (bytes[9] << 8);
756
+ return { width, height };
757
+ }
758
+
759
+ return null;
599
760
  }
600
761
 
601
762
  function convertFootnoteRef(node: MarkdownFootnoteReference, ctx: ExportContext): string {
@@ -632,7 +793,7 @@ async function buildDocxPackage(
632
793
  pkg.addPart('word/document.xml', documentXml, CONTENT_TYPE_DOCX_DOCUMENT);
633
794
 
634
795
  // --- word/styles.xml ---
635
- const stylesXml = buildStylesXml(options);
796
+ const stylesXml = buildStylesXml(options, ctx);
636
797
  pkg.addPart('word/styles.xml', stylesXml, CONTENT_TYPE_DOCX_STYLES);
637
798
 
638
799
  // --- word/settings.xml ---
@@ -754,9 +915,9 @@ function buildDocumentXml(bodyXml: string): string {
754
915
  );
755
916
  }
756
917
 
757
- function buildStylesXml(options: DocxExportOptions): string {
758
- const font = options.defaultFont ?? DEFAULT_FONT;
759
- const headingFont = DEFAULT_HEADING_FONT;
918
+ function buildStylesXml(options: DocxExportOptions, ctx: ExportContext): string {
919
+ const font = ctx.font;
920
+ const headingFont = ctx.headingFont;
760
921
 
761
922
  return (
762
923
  xmlDeclaration() +
@@ -776,7 +937,7 @@ function buildStylesXml(options: DocxExportOptions): string {
776
937
  `<w:qFormat/>` +
777
938
  `</w:style>` +
778
939
  // Heading styles
779
- buildHeadingStyles(headingFont) +
940
+ buildHeadingStyles(headingFont, ctx.headingColor) +
780
941
  // Quote style
781
942
  `<w:style w:type="paragraph" w:styleId="Quote">` +
782
943
  `<w:name w:val="Quote"/>` +
@@ -827,11 +988,12 @@ function buildStylesXml(options: DocxExportOptions): string {
827
988
  );
828
989
  }
829
990
 
830
- function buildHeadingStyles(headingFont: string): string {
991
+ function buildHeadingStyles(headingFont: string, headingColor?: string): string {
831
992
  let result = '';
832
993
  for (let depth = 1; depth <= 6; depth++) {
833
994
  const styleId = DEPTH_TO_STYLE_ID[depth];
834
995
  const fontSize = HEADING_FONT_SIZES[depth] ?? 22;
996
+ const colorXml = headingColor ? `<w:color w:val="${headingColor}"/>` : '';
835
997
  result +=
836
998
  `<w:style w:type="paragraph" w:styleId="${styleId}">` +
837
999
  `<w:name w:val="heading ${depth}"/>` +
@@ -842,6 +1004,7 @@ function buildHeadingStyles(headingFont: string): string {
842
1004
  `<w:rPr>` +
843
1005
  `<w:rFonts w:ascii="${escapeXml(headingFont)}" w:hAnsi="${escapeXml(headingFont)}"/>` +
844
1006
  `<w:b/>` +
1007
+ colorXml +
845
1008
  `<w:sz w:val="${fontSize}"/>` +
846
1009
  `<w:szCs w:val="${fontSize}"/>` +
847
1010
  `</w:rPr>` +
@@ -46,9 +46,11 @@ import type {
46
46
  MarkdownFootnoteDefinition,
47
47
  } from '@bendyline/squisq/markdown';
48
48
 
49
- import { openPackage, getPartXml, getPartRelationships } from '../ooxml/reader.js';
49
+ import { openPackage, getPartXml, getPartBinary, getPartRelationships } from '../ooxml/reader.js';
50
50
  import type { OoxmlPackage, Relationship } from '../ooxml/types.js';
51
51
  import { NS_WML, NS_R } from '../ooxml/namespaces.js';
52
+ import { MemoryContentContainer } from '@bendyline/squisq/storage';
53
+ import type { ContentContainer } from '@bendyline/squisq/storage';
52
54
  import {
53
55
  HEADING_STYLE_MAP,
54
56
  QUOTE_STYLE_IDS,
@@ -119,6 +121,56 @@ export async function docxToDoc(
119
121
  return markdownToDoc(markdownDoc);
120
122
  }
121
123
 
124
+ /**
125
+ * Convert a .docx file to a ContentContainer with markdown + extracted images.
126
+ *
127
+ * The container will contain:
128
+ * - The primary markdown document (index.md)
129
+ * - Any embedded images under images/ (e.g., images/image1.png)
130
+ *
131
+ * @param data - The raw .docx file as ArrayBuffer or Blob
132
+ * @param options - Import options
133
+ * @returns A ContentContainer with the document and its media
134
+ */
135
+ export async function docxToContainer(
136
+ data: ArrayBuffer | Blob,
137
+ options: DocxImportOptions = {},
138
+ ): Promise<ContentContainer> {
139
+ const pkg = await openPackage(data);
140
+ const ctx = await buildImportContext(pkg, { ...options, extractImages: true });
141
+
142
+ const documentXml = await getPartXml(pkg, 'word/document.xml');
143
+ if (!documentXml) {
144
+ const container = new MemoryContentContainer();
145
+ await container.writeDocument('');
146
+ return container;
147
+ }
148
+
149
+ const body = getFirstElement(documentXml, 'body');
150
+ if (!body) {
151
+ const container = new MemoryContentContainer();
152
+ await container.writeDocument('');
153
+ return container;
154
+ }
155
+
156
+ const blocks = await convertBody(body, ctx);
157
+ const markdownDoc: MarkdownDocument = { type: 'document', children: blocks };
158
+
159
+ // Serialize to markdown
160
+ const { stringifyMarkdown } = await import('@bendyline/squisq/markdown');
161
+ const markdown = stringifyMarkdown(markdownDoc);
162
+
163
+ // Build container with markdown + images
164
+ const container = new MemoryContentContainer();
165
+ await container.writeDocument(markdown);
166
+
167
+ for (const [path, { data: imageData, mimeType }] of ctx.extractedImages) {
168
+ await container.writeFile(path, new Uint8Array(imageData), mimeType);
169
+ }
170
+
171
+ return container;
172
+ }
173
+
122
174
  // ============================================
123
175
  // Import Context
124
176
  // ============================================
@@ -142,6 +194,10 @@ interface ImportContext {
142
194
  pkg: OoxmlPackage;
143
195
  /** Import options */
144
196
  options: DocxImportOptions;
197
+ /** Collected image files: relative path → { data, mimeType } */
198
+ extractedImages: Map<string, { data: ArrayBuffer; mimeType: string }>;
199
+ /** Counter for generating unique image filenames */
200
+ imageCounter: number;
145
201
  }
146
202
 
147
203
  interface NumberingInfo {
@@ -162,6 +218,8 @@ async function buildImportContext(
162
218
  footnotes: new Map(),
163
219
  pkg,
164
220
  options,
221
+ extractedImages: new Map(),
222
+ imageCounter: 0,
165
223
  };
166
224
 
167
225
  // Initialize with built-in defaults
@@ -544,20 +602,79 @@ async function convertHyperlink(el: Element, ctx: ImportContext): Promise<Markdo
544
602
  // Image Extraction
545
603
  // ============================================
546
604
 
547
- async function extractImage(_el: Element, _ctx: ImportContext): Promise<MarkdownImage | null> {
548
- // Image extraction is complex (DrawingML with blip references).
549
- // For v1, emit a placeholder. Full implementation requires:
550
- // 1. Find <a:blip r:embed="rId_"/> in the drawing tree
551
- // 2. Resolve the rId to a media path
552
- // 3. Extract the binary data and encode as data URI
553
- // This is planned for a future enhancement.
605
+ async function extractImage(el: Element, ctx: ImportContext): Promise<MarkdownImage | null> {
606
+ // Find <a:blip r:embed="rIdX"/> anywhere in the drawing tree
607
+ const blip = findDescendant(el, 'blip');
608
+ if (!blip) {
609
+ return { type: 'image', url: '', alt: 'Image' };
610
+ }
611
+
612
+ const rId = blip.getAttributeNS(NS_R, 'embed') ?? blip.getAttribute('r:embed');
613
+ if (!rId) {
614
+ return { type: 'image', url: '', alt: 'Image' };
615
+ }
616
+
617
+ const rel = ctx.documentRels.get(rId);
618
+ if (!rel) {
619
+ return { type: 'image', url: '', alt: 'Image' };
620
+ }
621
+
622
+ // Resolve the target path relative to word/
623
+ const target = rel.target.startsWith('/') ? rel.target.slice(1) : `word/${rel.target}`;
624
+
625
+ // Extract binary data from the zip
626
+ const data = await getPartBinary(ctx.pkg, target);
627
+ if (!data) {
628
+ return { type: 'image', url: '', alt: 'Image' };
629
+ }
630
+
631
+ // Determine extension and MIME type
632
+ const dot = target.lastIndexOf('.');
633
+ const ext = dot !== -1 ? target.slice(dot).toLowerCase() : '.png';
634
+ const mimeType = IMAGE_MIME_MAP[ext] ?? 'application/octet-stream';
635
+
636
+ // Generate a unique image path
637
+ ctx.imageCounter++;
638
+ const imagePath = `images/image${ctx.imageCounter}${ext}`;
639
+
640
+ // Store the extracted image data
641
+ ctx.extractedImages.set(imagePath, { data, mimeType });
642
+
643
+ // Try to extract alt text from the drawing's docPr element
644
+ const docPr = findDescendant(el, 'docPr');
645
+ const alt = docPr?.getAttribute('descr') || docPr?.getAttribute('title') || 'Image';
646
+
554
647
  return {
555
648
  type: 'image',
556
- url: '',
557
- alt: 'Image',
649
+ url: imagePath,
650
+ alt,
558
651
  };
559
652
  }
560
653
 
654
+ const IMAGE_MIME_MAP: Record<string, string> = {
655
+ '.png': 'image/png',
656
+ '.jpg': 'image/jpeg',
657
+ '.jpeg': 'image/jpeg',
658
+ '.gif': 'image/gif',
659
+ '.bmp': 'image/bmp',
660
+ '.tiff': 'image/tiff',
661
+ '.tif': 'image/tiff',
662
+ '.svg': 'image/svg+xml',
663
+ '.webp': 'image/webp',
664
+ '.emf': 'image/emf',
665
+ '.wmf': 'image/wmf',
666
+ };
667
+
668
+ /** Recursively find the first descendant element with the given local name. */
669
+ function findDescendant(el: Element, localName: string): Element | null {
670
+ for (const child of Array.from(el.children)) {
671
+ if (child.localName === localName) return child;
672
+ const found = findDescendant(child, localName);
673
+ if (found) return found;
674
+ }
675
+ return null;
676
+ }
677
+
561
678
  // ============================================
562
679
  // List Collection
563
680
  // ============================================
package/src/docx/index.ts CHANGED
@@ -22,5 +22,5 @@ export { markdownDocToDocx, docToDocx } from './export.js';
22
22
  export type { DocxExportOptions } from './export.js';
23
23
 
24
24
  // Import
25
- export { docxToMarkdownDoc, docxToDoc } from './import.js';
25
+ export { docxToMarkdownDoc, docxToDoc, docxToContainer } from './import.js';
26
26
  export type { DocxImportOptions } from './import.js';
@@ -41,6 +41,13 @@ export interface HtmlExportOptions {
41
41
 
42
42
  /** Auto-play slideshow on load (default: false) */
43
43
  autoPlay?: boolean;
44
+
45
+ /**
46
+ * Squisq theme ID to apply (e.g., 'documentary', 'cinematic').
47
+ * When set, the theme is assigned to the Doc before rendering,
48
+ * so the SquisqPlayer renders with that theme's colors and typography.
49
+ */
50
+ themeId?: string;
44
51
  }
45
52
 
46
53
  // ── Image Path Collection ──────────────────────────────────────────
@@ -182,8 +189,14 @@ export function generateInlineHtml(doc: Doc, options: HtmlExportOptions): string
182
189
  mode = 'slideshow',
183
190
  title = 'Squisq Document',
184
191
  autoPlay = false,
192
+ themeId,
185
193
  } = options;
186
194
 
195
+ // Apply theme to doc if specified (the SquisqPlayer respects doc.themeId at render time)
196
+ if (themeId) {
197
+ doc = { ...doc, themeId };
198
+ }
199
+
187
200
  // Build base64 image map
188
201
  const imageMap: Record<string, string> = {};
189
202
  if (images) {
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * Supported formats:
8
8
  * - **DOCX** — Microsoft Word (import + export) ✅
9
9
  * - **PDF** — Portable Document Format (import + export) ✅
10
- * - **PPTX** — Microsoft PowerPoint (planned)
10
+ * - **PPTX** — Microsoft PowerPoint (export ✅, import planned)
11
11
  * - **XLSX** — Microsoft Excel (planned)
12
12
  *
13
13
  * All converters run in the browser — no server or native binaries required.
@@ -28,7 +28,7 @@
28
28
  export { markdownDocToDocx, docToDocx, docxToMarkdownDoc, docxToDoc } from './docx/index.js';
29
29
  export type { DocxExportOptions, DocxImportOptions } from './docx/index.js';
30
30
 
31
- // PPTX (stub)
31
+ // PPTX (export implemented, import stub)
32
32
  export { markdownDocToPptx, docToPptx, pptxToMarkdownDoc, pptxToDoc } from './pptx/index.js';
33
33
  export type { PptxExportOptions, PptxImportOptions } from './pptx/index.js';
34
34
 
@@ -83,5 +83,12 @@ export {
83
83
  CONTENT_TYPE_DOCX_FONT_TABLE,
84
84
  CONTENT_TYPE_DOCX_FOOTNOTES,
85
85
  CONTENT_TYPE_PPTX_PRESENTATION,
86
+ CONTENT_TYPE_PPTX_SLIDE,
87
+ CONTENT_TYPE_PPTX_SLIDE_LAYOUT,
88
+ CONTENT_TYPE_PPTX_SLIDE_MASTER,
89
+ CONTENT_TYPE_PPTX_THEME,
90
+ REL_SLIDE,
91
+ REL_SLIDE_LAYOUT,
92
+ REL_SLIDE_MASTER,
86
93
  CONTENT_TYPE_XLSX_WORKBOOK,
87
94
  } from './namespaces.js';
@@ -156,5 +156,28 @@ export const CONTENT_TYPE_DOCX_FOOTNOTES =
156
156
  export const CONTENT_TYPE_PPTX_PRESENTATION =
157
157
  'application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml';
158
158
 
159
+ export const CONTENT_TYPE_PPTX_SLIDE =
160
+ 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml';
161
+
162
+ export const CONTENT_TYPE_PPTX_SLIDE_LAYOUT =
163
+ 'application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml';
164
+
165
+ export const CONTENT_TYPE_PPTX_SLIDE_MASTER =
166
+ 'application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml';
167
+
168
+ export const CONTENT_TYPE_PPTX_THEME = 'application/vnd.openxmlformats-officedocument.theme+xml';
169
+
170
+ /** Relationship type: Slide */
171
+ export const REL_SLIDE =
172
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide';
173
+
174
+ /** Relationship type: Slide layout */
175
+ export const REL_SLIDE_LAYOUT =
176
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout';
177
+
178
+ /** Relationship type: Slide master */
179
+ export const REL_SLIDE_MASTER =
180
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster';
181
+
159
182
  export const CONTENT_TYPE_XLSX_WORKBOOK =
160
183
  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml';