@bendyline/squisq-formats 1.1.1 → 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.
@@ -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';
package/src/pdf/import.ts CHANGED
@@ -40,8 +40,12 @@ import type {
40
40
  MarkdownStrong,
41
41
  MarkdownInlineCode,
42
42
  MarkdownLink,
43
+ MarkdownImage,
43
44
  } from '@bendyline/squisq/markdown';
44
45
 
46
+ import { MemoryContentContainer } from '@bendyline/squisq/storage';
47
+ import type { ContentContainer } from '@bendyline/squisq/storage';
48
+
45
49
  import {
46
50
  DEFAULT_FONT_SIZE,
47
51
  IMPORT_HEADING_MIN_SIZE,
@@ -127,6 +131,230 @@ export async function pdfToDoc(
127
131
  return markdownToDoc(markdownDoc);
128
132
  }
129
133
 
134
+ /**
135
+ * Convert a PDF file to a ContentContainer with markdown + extracted images.
136
+ *
137
+ * The container will contain:
138
+ * - The primary markdown document (index.md)
139
+ * - Any embedded images under images/ (e.g., images/image1.png)
140
+ *
141
+ * Image extraction uses pdfjs-dist's operator list API and requires a browser
142
+ * environment (canvas is used to encode pixel data to PNG).
143
+ *
144
+ * @param data - The raw PDF file as ArrayBuffer, Uint8Array, or Blob
145
+ * @param options - Import options
146
+ * @returns A ContentContainer with the document and its media
147
+ */
148
+ export async function pdfToContainer(
149
+ data: ArrayBuffer | Uint8Array | Blob,
150
+ options: PdfImportOptions = {},
151
+ ): Promise<ContentContainer> {
152
+ const bytes =
153
+ data instanceof Blob
154
+ ? new Uint8Array(await data.arrayBuffer())
155
+ : data instanceof ArrayBuffer
156
+ ? new Uint8Array(data)
157
+ : data;
158
+
159
+ const textLines = await extractTextLines(bytes);
160
+ const images = await extractImages(bytes);
161
+
162
+ const bodySize = options.bodyFontSize ?? detectBodyFontSize(textLines);
163
+
164
+ // If we have images, insert image references into the block list
165
+ let blocks = classifyLines(textLines, bodySize, options);
166
+ if (images.length > 0) {
167
+ blocks = insertImageBlocks(blocks, images);
168
+ }
169
+
170
+ const markdownDoc: MarkdownDocument = { type: 'document', children: blocks };
171
+
172
+ const { stringifyMarkdown } = await import('@bendyline/squisq/markdown');
173
+ const markdown = stringifyMarkdown(markdownDoc);
174
+
175
+ const container = new MemoryContentContainer();
176
+ await container.writeDocument(markdown);
177
+
178
+ for (const img of images) {
179
+ await container.writeFile(img.path, new Uint8Array(img.data), 'image/png');
180
+ }
181
+
182
+ return container;
183
+ }
184
+
185
+ /** Extracted image with position info for placement. */
186
+ interface ExtractedImage {
187
+ path: string;
188
+ data: ArrayBuffer;
189
+ page: number;
190
+ y: number;
191
+ }
192
+
193
+ /**
194
+ * Extract embedded images from a PDF using pdfjs-dist operator list API.
195
+ * Requires browser canvas for PNG encoding.
196
+ */
197
+ async function extractImages(data: Uint8Array): Promise<ExtractedImage[]> {
198
+ // Canvas is required for PNG encoding — skip in non-browser environments
199
+ if (typeof document === 'undefined') return [];
200
+
201
+ let pdfjsLib: PdfjsLib & { OPS?: Record<string, number> };
202
+ try {
203
+ pdfjsLib = (await import('pdfjs-dist/legacy/build/pdf.mjs')) as unknown as PdfjsLib & {
204
+ OPS?: Record<string, number>;
205
+ };
206
+ } catch {
207
+ pdfjsLib = (await import('pdfjs-dist')) as unknown as PdfjsLib & {
208
+ OPS?: Record<string, number>;
209
+ };
210
+ }
211
+
212
+ await applyWorkerConfig(pdfjsLib);
213
+
214
+ const OPS_paintImageXObject = pdfjsLib.OPS?.paintImageXObject ?? 85;
215
+
216
+ const loadingTask = pdfjsLib.getDocument({
217
+ data,
218
+ isEvalSupported: false,
219
+ useSystemFonts: true,
220
+ });
221
+
222
+ const pdf = await loadingTask.promise;
223
+ const images: ExtractedImage[] = [];
224
+ let counter = 0;
225
+
226
+ for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
227
+ const page = (await pdf.getPage(pageNum)) as PdfjsPageFull;
228
+ if (!page.getOperatorList) continue;
229
+
230
+ const opList = await page.getOperatorList();
231
+ const seen = new Set<string>();
232
+
233
+ for (let i = 0; i < opList.fnArray.length; i++) {
234
+ if (opList.fnArray[i] !== OPS_paintImageXObject) continue;
235
+
236
+ const imgName = opList.argsArray[i]?.[0];
237
+ if (!imgName || typeof imgName !== 'string' || seen.has(imgName)) continue;
238
+ seen.add(imgName);
239
+
240
+ try {
241
+ const imgData = page.objs?.get(imgName) as PdfjsImageData | null;
242
+ if (!imgData?.data || !imgData.width || !imgData.height) continue;
243
+
244
+ const pngData = imageDataToPng(imgData);
245
+ if (!pngData) continue;
246
+
247
+ counter++;
248
+ images.push({
249
+ path: `images/image${counter}.png`,
250
+ data: pngData,
251
+ page: pageNum - 1,
252
+ y: 0,
253
+ });
254
+ } catch {
255
+ // Skip images that fail to extract
256
+ }
257
+ }
258
+ }
259
+
260
+ return images;
261
+ }
262
+
263
+ /** Minimal pdfjs image data shape. */
264
+ interface PdfjsImageData {
265
+ width: number;
266
+ height: number;
267
+ data: Uint8ClampedArray;
268
+ kind?: number;
269
+ }
270
+
271
+ /** Extended PdfjsPage with operator list and objs access. */
272
+ interface PdfjsPageFull extends PdfjsPage {
273
+ getOperatorList(): Promise<{ fnArray: number[]; argsArray: unknown[][] }>;
274
+ objs?: { get(name: string): unknown; has?(name: string): boolean };
275
+ }
276
+
277
+ /** Encode pdfjs image data to PNG using a canvas element. */
278
+ function imageDataToPng(img: PdfjsImageData): ArrayBuffer | null {
279
+ try {
280
+ const canvas = document.createElement('canvas');
281
+ canvas.width = img.width;
282
+ canvas.height = img.height;
283
+ const ctx = canvas.getContext('2d');
284
+ if (!ctx) return null;
285
+
286
+ // pdfjs kind=1 is GRAYSCALE, kind=2 is RGB, kind=3 is RGBA
287
+ let imageData: ImageData;
288
+ if (img.kind === 3 || img.data.length === img.width * img.height * 4) {
289
+ // RGBA — use directly
290
+ imageData = new ImageData(new Uint8ClampedArray(img.data), img.width, img.height);
291
+ } else if (img.kind === 2 || img.data.length === img.width * img.height * 3) {
292
+ // RGB — expand to RGBA
293
+ const rgba = new Uint8ClampedArray(img.width * img.height * 4);
294
+ for (let j = 0, k = 0; j < img.data.length; j += 3, k += 4) {
295
+ rgba[k] = img.data[j];
296
+ rgba[k + 1] = img.data[j + 1];
297
+ rgba[k + 2] = img.data[j + 2];
298
+ rgba[k + 3] = 255;
299
+ }
300
+ imageData = new ImageData(rgba, img.width, img.height);
301
+ } else if (img.kind === 1 || img.data.length === img.width * img.height) {
302
+ // Grayscale — expand to RGBA
303
+ const rgba = new Uint8ClampedArray(img.width * img.height * 4);
304
+ for (let j = 0, k = 0; j < img.data.length; j++, k += 4) {
305
+ rgba[k] = img.data[j];
306
+ rgba[k + 1] = img.data[j];
307
+ rgba[k + 2] = img.data[j];
308
+ rgba[k + 3] = 255;
309
+ }
310
+ imageData = new ImageData(rgba, img.width, img.height);
311
+ } else {
312
+ return null;
313
+ }
314
+
315
+ ctx.putImageData(imageData, 0, 0);
316
+
317
+ // Convert canvas to PNG ArrayBuffer
318
+ const dataUrl = canvas.toDataURL('image/png');
319
+ const base64 = dataUrl.split(',')[1];
320
+ const binaryStr = atob(base64);
321
+ const bytes = new Uint8Array(binaryStr.length);
322
+ for (let i = 0; i < binaryStr.length; i++) {
323
+ bytes[i] = binaryStr.charCodeAt(i);
324
+ }
325
+ return bytes.buffer;
326
+ } catch {
327
+ return null;
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Insert image reference blocks between text blocks.
333
+ * Places each image after the last text block on the same page (or at the end).
334
+ */
335
+ function insertImageBlocks(
336
+ blocks: MarkdownBlockNode[],
337
+ images: ExtractedImage[],
338
+ ): MarkdownBlockNode[] {
339
+ if (images.length === 0) return blocks;
340
+
341
+ // Simple strategy: append all images at the end as paragraphs
342
+ const result = [...blocks];
343
+ for (const img of images) {
344
+ const imgNode: MarkdownImage = {
345
+ type: 'image',
346
+ url: img.path,
347
+ alt: `Image ${img.path.replace('images/image', '').replace('.png', '')}`,
348
+ };
349
+ const para: MarkdownParagraph = {
350
+ type: 'paragraph',
351
+ children: [imgNode],
352
+ };
353
+ result.push(para);
354
+ }
355
+ return result;
356
+ }
357
+
130
358
  // ============================================
131
359
  // Internal Types
132
360
  // ============================================
package/src/pdf/index.ts CHANGED
@@ -25,5 +25,5 @@ export { markdownDocToPdf, docToPdf } from './export.js';
25
25
  export type { PdfExportOptions } from './export.js';
26
26
 
27
27
  // Import
28
- export { pdfToMarkdownDoc, pdfToDoc, configurePdfWorker } from './import.js';
28
+ export { pdfToMarkdownDoc, pdfToDoc, pdfToContainer, configurePdfWorker } from './import.js';
29
29
  export type { PdfImportOptions } from './import.js';