@bendyline/squisq-formats 1.1.1 → 1.2.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 (69) hide show
  1. package/README.md +14 -0
  2. package/dist/__tests__/epub.test.d.ts +8 -0
  3. package/dist/__tests__/epub.test.d.ts.map +1 -0
  4. package/dist/__tests__/epub.test.js +472 -0
  5. package/dist/__tests__/epub.test.js.map +1 -0
  6. package/dist/chunk-2FPJKJ6I.js +621 -0
  7. package/dist/chunk-2FPJKJ6I.js.map +1 -0
  8. package/dist/{chunk-BHHEDPRB.js → chunk-7DEUGIOO.js} +6 -41
  9. package/dist/chunk-7DEUGIOO.js.map +1 -0
  10. package/dist/chunk-A3FHLTY5.js +42 -0
  11. package/dist/chunk-A3FHLTY5.js.map +1 -0
  12. package/dist/{chunk-6OVREALI.js → chunk-MEZF76JA.js} +168 -14
  13. package/dist/chunk-MEZF76JA.js.map +1 -0
  14. package/dist/{chunk-FIFCQN3W.js → chunk-NGWHV77G.js} +5 -3
  15. package/dist/{chunk-FIFCQN3W.js.map → chunk-NGWHV77G.js.map} +1 -1
  16. package/dist/{chunk-PP5N46YD.js → chunk-S3Y7H2BK.js} +129 -1
  17. package/dist/chunk-S3Y7H2BK.js.map +1 -0
  18. package/dist/chunk-U4MRIFKL.js +43 -0
  19. package/dist/chunk-U4MRIFKL.js.map +1 -0
  20. package/dist/{chunk-743COJWQ.js → chunk-UM5V2XZG.js} +7 -40
  21. package/dist/chunk-UM5V2XZG.js.map +1 -0
  22. package/dist/{chunk-KJ4NS4DX.js → chunk-YN5HFCEW.js} +2 -2
  23. package/dist/docx/export.d.ts +9 -0
  24. package/dist/docx/export.d.ts.map +1 -1
  25. package/dist/docx/export.js +120 -12
  26. package/dist/docx/export.js.map +1 -1
  27. package/dist/docx/import.d.ts +13 -0
  28. package/dist/docx/import.d.ts.map +1 -1
  29. package/dist/docx/import.js +102 -10
  30. package/dist/docx/import.js.map +1 -1
  31. package/dist/docx/index.d.ts +1 -1
  32. package/dist/docx/index.d.ts.map +1 -1
  33. package/dist/docx/index.js +1 -1
  34. package/dist/docx/index.js.map +1 -1
  35. package/dist/epub/export.d.ts +72 -0
  36. package/dist/epub/export.d.ts.map +1 -0
  37. package/dist/epub/export.js +674 -0
  38. package/dist/epub/export.js.map +1 -0
  39. package/dist/epub/index.d.ts +20 -0
  40. package/dist/epub/index.d.ts.map +1 -0
  41. package/dist/epub/index.js +19 -0
  42. package/dist/epub/index.js.map +1 -0
  43. package/dist/index.d.ts +2 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +2 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/pdf/import.d.ts +16 -0
  48. package/dist/pdf/import.d.ts.map +1 -1
  49. package/dist/pdf/import.js +177 -0
  50. package/dist/pdf/import.js.map +1 -1
  51. package/dist/pdf/index.d.ts +1 -1
  52. package/dist/pdf/index.d.ts.map +1 -1
  53. package/dist/pdf/index.js +1 -1
  54. package/dist/pdf/index.js.map +1 -1
  55. package/package.json +13 -7
  56. package/src/__tests__/epub.test.ts +548 -0
  57. package/src/docx/export.ts +147 -12
  58. package/src/docx/import.ts +127 -10
  59. package/src/docx/index.ts +1 -1
  60. package/src/epub/export.ts +891 -0
  61. package/src/epub/index.ts +20 -0
  62. package/src/index.ts +4 -0
  63. package/src/pdf/import.ts +228 -0
  64. package/src/pdf/index.ts +1 -1
  65. package/dist/chunk-6OVREALI.js.map +0 -1
  66. package/dist/chunk-743COJWQ.js.map +0 -1
  67. package/dist/chunk-BHHEDPRB.js.map +0 -1
  68. package/dist/chunk-PP5N46YD.js.map +0 -1
  69. /package/dist/{chunk-KJ4NS4DX.js.map → chunk-YN5HFCEW.js.map} +0 -0
@@ -53,6 +53,7 @@ import {
53
53
  REL_SETTINGS,
54
54
  REL_FONT_TABLE,
55
55
  REL_HYPERLINK,
56
+ REL_IMAGE,
56
57
  REL_FOOTNOTES,
57
58
  CONTENT_TYPE_DOCX_DOCUMENT,
58
59
  CONTENT_TYPE_DOCX_STYLES,
@@ -97,6 +98,12 @@ export interface DocxExportOptions {
97
98
  * the theme's primary color to headings.
98
99
  */
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 }>;
100
107
  }
101
108
 
102
109
  /**
@@ -179,6 +186,11 @@ class ExportContext {
179
186
  /** Heading text color (hex without #), or undefined for default */
180
187
  readonly headingColor: string | undefined;
181
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;
193
+
182
194
  constructor(options: DocxExportOptions) {
183
195
  let themeFont: string | undefined;
184
196
  let themeTitleFont: string | undefined;
@@ -200,6 +212,7 @@ class ExportContext {
200
212
  ? options.defaultFontSize * 2
201
213
  : DEFAULT_FONT_SIZE_HALF_POINTS;
202
214
  this.headingColor = themeHeadingColor;
215
+ this.resolvedImages = options.images ?? new Map();
203
216
  }
204
217
 
205
218
  /** Allocate a new relationship ID */
@@ -219,6 +232,26 @@ class ExportContext {
219
232
  return id;
220
233
  }
221
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
+
222
255
  /** Allocate a numbering definition for a list */
223
256
  allocNumbering(ordered: boolean): number {
224
257
  const numId = this.nextNumId++;
@@ -535,7 +568,7 @@ function convertInline(node: MarkdownInlineNode, ctx: ExportContext, format: Inl
535
568
  case 'link':
536
569
  return convertLink(node, ctx, format);
537
570
  case 'image':
538
- return convertImage(node);
571
+ return convertImage(node, ctx);
539
572
  case 'break':
540
573
  return `<w:r><w:br/></w:r>`;
541
574
  case 'htmlInline':
@@ -572,10 +605,6 @@ function makeRun(text: string, format: InlineFormat): string {
572
605
 
573
606
  function convertLink(node: MarkdownLink, ctx: ExportContext, format: InlineFormat): string {
574
607
  const rId = ctx.addHyperlink(node.url);
575
- const _runs = convertInlines(node.children, ctx, { ...format });
576
-
577
- // Wrap each run's rPr with hyperlink styling
578
- // For simplicity, emit inline runs with hyperlink color + underline
579
608
  const styledRuns = convertInlinesWithHyperlinkStyle(node.children, ctx, format);
580
609
 
581
610
  return `<w:hyperlink r:id="${rId}">${styledRuns}</w:hyperlink>`;
@@ -615,13 +644,119 @@ function makeHyperlinkRun(text: string, format: InlineFormat): string {
615
644
  );
616
645
  }
617
646
 
618
- function convertImage(_node: MarkdownImage): string {
619
- // Image embedding requires fetching the image data and packing it as
620
- // a binary part. For now, emit a placeholder text run.
621
- // Full image support can be added by fetching the URL and creating
622
- // a DrawingML inline + binary media part.
623
- const alt = _node.alt || _node.url;
624
- 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;
625
760
  }
626
761
 
627
762
  function convertFootnoteRef(node: MarkdownFootnoteReference, ctx: ExportContext): string {
@@ -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';