@file-viewer/doc 2.1.22 → 2.1.24

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.
@@ -1,5 +1,23 @@
1
1
  import { BinaryReader } from '../core/binary.js';
2
+ import { inflateZlib } from '../core/inflate.js';
2
3
  import { dataUrlFromBytes, slugify, uniqueId } from '../core/utils.js';
4
+ import { detectMimeFromBlipType, parseInlineOfficeArtShape } from './officeart.js';
5
+ import { convertMetafileToSvg } from './vector.js';
6
+ const DEFAULT_MAX_PICTURE_BYTES = 8 * 1024 * 1024;
7
+ const PICF_HEADER_SIZE = 68;
8
+ const MM_SHAPE = 0x0064;
9
+ const MM_SHAPEFILE = 0x0066;
10
+ const OFFICEART_CONTAINER = 0xf;
11
+ const OFFICEART_TYPE_INLINE_SP_CONTAINER = 0xf004;
12
+ const OFFICEART_FBSE = 0xf007;
13
+ const OFFICEART_BLIP_EMF = 0xf01a;
14
+ const OFFICEART_BLIP_WMF = 0xf01b;
15
+ const OFFICEART_BLIP_PICT = 0xf01c;
16
+ const OFFICEART_BLIP_JPEG = 0xf01d;
17
+ const OFFICEART_BLIP_PNG = 0xf01e;
18
+ const OFFICEART_BLIP_DIB = 0xf01f;
19
+ const OFFICEART_BLIP_TIFF = 0xf029;
20
+ const OFFICEART_BLIP_JPEG_ALT = 0xf02a;
3
21
  function startsWith(bytes, signature, offset = 0) {
4
22
  if (offset + signature.length > bytes.length)
5
23
  return false;
@@ -9,6 +27,86 @@ function startsWith(bytes, signature, offset = 0) {
9
27
  }
10
28
  return true;
11
29
  }
30
+ function isPrintableAnsi(bytes) {
31
+ for (let i = 0; i < bytes.length; i += 1) {
32
+ const value = bytes[i] ?? 0;
33
+ if (value === 0 || value === 9 || value === 10 || value === 13)
34
+ continue;
35
+ if (value < 0x20 || value === 0x7f)
36
+ return false;
37
+ }
38
+ return true;
39
+ }
40
+ function isProbablyPicturePath(value) {
41
+ const normalized = value.trim();
42
+ if (!normalized)
43
+ return false;
44
+ if (/^(?:file|https?|data|blob):/i.test(normalized))
45
+ return true;
46
+ if (/^(?:\\\\|[a-zA-Z]:[\\/]|\/)/.test(normalized))
47
+ return true;
48
+ return /\.(?:png|apng|jpe?g|gif|bmp|dib|tiff?|emf|wmf|pict|svg)(?:$|[?#])/i.test(normalized);
49
+ }
50
+ function isLocalExternalPath(value) {
51
+ return /^file:/i.test(value) || /^(?:\\\\|[a-zA-Z]:[\\/]|\/)/.test(value);
52
+ }
53
+ function detectMimeFromPath(path) {
54
+ const normalized = path.toLowerCase();
55
+ if (normalized.includes('.png'))
56
+ return 'image/png';
57
+ if (normalized.includes('.jpg') || normalized.includes('.jpeg'))
58
+ return 'image/jpeg';
59
+ if (normalized.includes('.gif'))
60
+ return 'image/gif';
61
+ if (normalized.includes('.bmp'))
62
+ return 'image/bmp';
63
+ if (normalized.includes('.dib'))
64
+ return 'image/bmp';
65
+ if (normalized.includes('.tif'))
66
+ return 'image/tiff';
67
+ if (normalized.includes('.emf'))
68
+ return 'image/emf';
69
+ if (normalized.includes('.wmf'))
70
+ return 'image/wmf';
71
+ if (normalized.includes('.pict') || normalized.includes('.pct') || normalized.includes('.pic'))
72
+ return 'image/pict';
73
+ if (normalized.includes('.svg'))
74
+ return 'image/svg+xml';
75
+ return null;
76
+ }
77
+ function isBrowserDisplayableMime(mime) {
78
+ return /^(?:image\/png|image\/jpeg|image\/gif|image\/bmp|image\/webp|image\/svg\+xml|image\/tiff)$/i.test(mime);
79
+ }
80
+ function detectAttachmentMime(name) {
81
+ const normalized = name.toLowerCase();
82
+ if (normalized.endsWith('.doc'))
83
+ return 'application/msword';
84
+ if (normalized.endsWith('.docx'))
85
+ return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
86
+ if (normalized.endsWith('.xls'))
87
+ return 'application/vnd.ms-excel';
88
+ if (normalized.endsWith('.xlsx'))
89
+ return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
90
+ if (normalized.endsWith('.ppt'))
91
+ return 'application/vnd.ms-powerpoint';
92
+ if (normalized.endsWith('.pptx'))
93
+ return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
94
+ if (normalized.endsWith('.pdf'))
95
+ return 'application/pdf';
96
+ if (normalized.endsWith('.png'))
97
+ return 'image/png';
98
+ if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg'))
99
+ return 'image/jpeg';
100
+ if (normalized.endsWith('.gif'))
101
+ return 'image/gif';
102
+ if (normalized.endsWith('.bmp'))
103
+ return 'image/bmp';
104
+ if (normalized.endsWith('.zip'))
105
+ return 'application/zip';
106
+ if (normalized.endsWith('.txt'))
107
+ return 'text/plain';
108
+ return 'application/octet-stream';
109
+ }
12
110
  export function detectImageSegment(bytes) {
13
111
  const sigs = [
14
112
  { mime: 'image/png', magic: [0x89, 0x50, 0x4e, 0x47], end: findPngEnd },
@@ -23,6 +121,8 @@ export function detectImageSegment(bytes) {
23
121
  for (let i = 0; i < bytes.length - 4; i += 1) {
24
122
  for (const sig of sigs) {
25
123
  if (startsWith(bytes, sig.magic, i)) {
124
+ if (sig.mime === 'image/emf' && findEmfEnd(bytes, i) === bytes.length)
125
+ continue;
26
126
  const end = sig.end ? sig.end(bytes, i) : bytes.length;
27
127
  return { mime: sig.mime, start: i, end: end || bytes.length };
28
128
  }
@@ -60,25 +160,426 @@ function findBmpEnd(bytes, start) {
60
160
  return bytes.length;
61
161
  }
62
162
  function findEmfEnd(bytes, start) {
63
- if (start + 48 <= bytes.length) {
64
- const reader = new BinaryReader(bytes.subarray(start));
65
- const size = reader.u32(40);
66
- if (size > 0 && start + size <= bytes.length)
67
- return start + size;
68
- }
163
+ if (start + 52 > bytes.length)
164
+ return bytes.length;
165
+ const reader = new BinaryReader(bytes.subarray(start));
166
+ const iType = reader.u32(0);
167
+ const signature = reader.u32(40);
168
+ const nBytes = reader.u32(48);
169
+ if (iType !== 0x00000001 || signature !== 0x464d4520)
170
+ return bytes.length;
171
+ if (nBytes > 0 && start + nBytes <= bytes.length)
172
+ return start + nBytes;
69
173
  return bytes.length;
70
174
  }
175
+ function parsePicfHeader(bytes) {
176
+ if (bytes.length < PICF_HEADER_SIZE)
177
+ return null;
178
+ const reader = new BinaryReader(bytes);
179
+ // PICMID stores the intended display goal size in twips plus mx/my scaling
180
+ // factors (tenths of a percent). Word uses these values for inline picture
181
+ // layout, including images that live in header/footer stories.
182
+ const dxaGoalTwips = reader.i16(28);
183
+ const dyaGoalTwips = reader.i16(30);
184
+ const scaleXPermille = reader.u16(32);
185
+ const scaleYPermille = reader.u16(34);
186
+ const displayWidthTwips = dxaGoalTwips > 0 && scaleXPermille > 0
187
+ ? Math.max(15, Math.min(31680, Math.round((dxaGoalTwips * scaleXPermille) / 1000)))
188
+ : undefined;
189
+ const displayHeightTwips = dyaGoalTwips > 0 && scaleYPermille > 0
190
+ ? Math.max(15, Math.min(31680, Math.round((dyaGoalTwips * scaleYPermille) / 1000)))
191
+ : undefined;
192
+ return {
193
+ lcb: reader.i32(0),
194
+ cbHeader: reader.u16(4),
195
+ mm: reader.u16(6),
196
+ xExt: reader.i16(8),
197
+ yExt: reader.i16(10),
198
+ dxaGoalTwips: dxaGoalTwips > 0 ? dxaGoalTwips : undefined,
199
+ dyaGoalTwips: dyaGoalTwips > 0 ? dyaGoalTwips : undefined,
200
+ scaleXPermille: scaleXPermille || undefined,
201
+ scaleYPermille: scaleYPermille || undefined,
202
+ displayWidthTwips,
203
+ displayHeightTwips,
204
+ };
205
+ }
206
+ /**
207
+ * PICFAndOfficeArtData can optionally carry a length-prefixed ANSI path right after
208
+ * the 68-byte PICF header. The spec ties this to MM_SHAPEFILE, but real-world files
209
+ * are not always perfectly spec-compliant, so we also accept clearly path-like data.
210
+ */
211
+ function readOptionalPictureName(pictureChunk, picf) {
212
+ const offset = Math.min(picf.cbHeader || PICF_HEADER_SIZE, pictureChunk.length);
213
+ if (offset >= pictureChunk.length)
214
+ return null;
215
+ const length = pictureChunk[offset] ?? 0;
216
+ if (!length || offset + 1 + length > pictureChunk.length)
217
+ return null;
218
+ const raw = pictureChunk.subarray(offset + 1, offset + 1 + length);
219
+ if (!isPrintableAnsi(raw))
220
+ return null;
221
+ const value = new TextDecoder('windows-1252').decode(raw).replace(/\0+$/g, '');
222
+ if (!isProbablyPicturePath(value)) {
223
+ if (picf.mm !== MM_SHAPEFILE)
224
+ return null;
225
+ }
226
+ return { value, nextOffset: offset + 1 + length };
227
+ }
228
+ /**
229
+ * OfficeArt records all start with the common 8-byte OfficeArtRecordHeader.
230
+ * Parsing this explicitly lets us walk inline shape containers instead of guessing
231
+ * image payloads by magic bytes, which was the root cause of the broken image output.
232
+ */
233
+ export function parseOfficeArtRecordHeader(bytes, offset) {
234
+ if (offset < 0 || offset + 8 > bytes.length)
235
+ return null;
236
+ const reader = new BinaryReader(bytes);
237
+ const versionAndInstance = reader.u16(offset);
238
+ const recType = reader.u16(offset + 2);
239
+ const recLen = reader.u32(offset + 4);
240
+ if (recType < 0xf000 || recType > 0xffff)
241
+ return null;
242
+ const size = 8 + recLen;
243
+ if (recLen > bytes.length || offset + size > bytes.length)
244
+ return null;
245
+ return {
246
+ recVer: versionAndInstance & 0x000f,
247
+ recInstance: versionAndInstance >>> 4,
248
+ recType,
249
+ recLen,
250
+ size,
251
+ };
252
+ }
253
+ function createBitmapFileHeader(pixelOffset, totalSize) {
254
+ const header = new Uint8Array(14);
255
+ const view = new DataView(header.buffer);
256
+ view.setUint8(0, 0x42);
257
+ view.setUint8(1, 0x4d);
258
+ view.setUint32(2, totalSize, true);
259
+ view.setUint16(6, 0, true);
260
+ view.setUint16(8, 0, true);
261
+ view.setUint32(10, pixelOffset, true);
262
+ return header;
263
+ }
264
+ function dibToBmp(dibBytes) {
265
+ if (dibBytes.length < 12)
266
+ return null;
267
+ const reader = new BinaryReader(dibBytes);
268
+ const headerSize = reader.u32(0);
269
+ if (headerSize < 12 || headerSize > dibBytes.length)
270
+ return null;
271
+ let bitsPerPixel = 0;
272
+ let compression = 0;
273
+ let colorsUsed = 0;
274
+ let paletteEntrySize = 4;
275
+ if (headerSize === 12) {
276
+ bitsPerPixel = reader.u16(10);
277
+ paletteEntrySize = 3;
278
+ }
279
+ else {
280
+ bitsPerPixel = reader.u16(14);
281
+ compression = reader.u32(16);
282
+ colorsUsed = reader.u32(32);
283
+ }
284
+ const colorCount = colorsUsed || (bitsPerPixel > 0 && bitsPerPixel <= 8 ? 1 << bitsPerPixel : 0);
285
+ let paletteSize = colorCount * paletteEntrySize;
286
+ if (compression === 3 && headerSize >= 40)
287
+ paletteSize += 12;
288
+ const pixelOffset = 14 + headerSize + paletteSize;
289
+ if (pixelOffset > 14 + dibBytes.length)
290
+ return null;
291
+ const bmpHeader = createBitmapFileHeader(pixelOffset, 14 + dibBytes.length);
292
+ const out = new Uint8Array(14 + dibBytes.length);
293
+ out.set(bmpHeader, 0);
294
+ out.set(dibBytes, 14);
295
+ return out;
296
+ }
297
+ /**
298
+ * OfficeArtBStoreContainerFileBlock can wrap an OfficeArtFBSE record instead of a
299
+ * BLIP atom directly. In that case the actual image payload sits in the FBSE
300
+ * embeddedBlip field after the fixed 36-byte FBSE header and optional nameData.
301
+ */
302
+ function extractEmbeddedBlipFromFbse(bytes, offset, header) {
303
+ const payloadOffset = offset + 8;
304
+ const payloadEnd = payloadOffset + header.recLen;
305
+ if (payloadEnd > bytes.length || header.recLen < 36)
306
+ return null;
307
+ const reader = new BinaryReader(bytes);
308
+ const btWin32 = reader.u8(payloadOffset);
309
+ const btMacOS = reader.u8(payloadOffset + 1);
310
+ const tag = reader.u16(payloadOffset + 18);
311
+ const size = reader.u32(payloadOffset + 20);
312
+ const cRef = reader.u32(payloadOffset + 24);
313
+ const foDelay = reader.u32(payloadOffset + 28);
314
+ const cbName = reader.u8(payloadOffset + 33);
315
+ const nameOffset = payloadOffset + 36;
316
+ const embeddedBlipOffset = nameOffset + cbName;
317
+ if (embeddedBlipOffset + 8 > payloadEnd)
318
+ return null;
319
+ const embeddedHeader = parseOfficeArtRecordHeader(bytes, embeddedBlipOffset);
320
+ if (!embeddedHeader)
321
+ return null;
322
+ const candidate = extractBlipPayload(bytes, embeddedBlipOffset, embeddedHeader);
323
+ if (!candidate)
324
+ return null;
325
+ const fbseName = cbName > 0 && nameOffset + cbName <= payloadEnd
326
+ ? new TextDecoder('utf-16le').decode(bytes.subarray(nameOffset, nameOffset + Math.max(0, cbName - 2))).replace(/\0+$/g, '')
327
+ : '';
328
+ return {
329
+ ...candidate,
330
+ meta: {
331
+ ...(candidate.meta || {}),
332
+ fbseBlipType: btWin32 || btMacOS,
333
+ fbseMime: detectMimeFromBlipType(btWin32 || btMacOS),
334
+ fbseTag: tag,
335
+ fbseSize: size,
336
+ fbseRefCount: cRef,
337
+ fbseDelayOffset: foDelay,
338
+ fbseName,
339
+ },
340
+ };
341
+ }
342
+ function extractBlipPayload(bytes, offset, header) {
343
+ const payloadOffset = offset + 8;
344
+ const uidCount = (header.recInstance & 0x1) === 1 ? 2 : 1;
345
+ const uidBytes = uidCount * 16;
346
+ const atom = bytes.subarray(payloadOffset, payloadOffset + header.recLen);
347
+ if (header.recType === OFFICEART_BLIP_PNG || header.recType === OFFICEART_BLIP_JPEG || header.recType === OFFICEART_BLIP_JPEG_ALT || header.recType === OFFICEART_BLIP_DIB || header.recType === OFFICEART_BLIP_TIFF) {
348
+ const infoSize = uidBytes + 1;
349
+ if (atom.length < infoSize)
350
+ return null;
351
+ let mime = 'application/octet-stream';
352
+ let payload = atom.subarray(infoSize);
353
+ if (header.recType === OFFICEART_BLIP_PNG)
354
+ mime = 'image/png';
355
+ else if (header.recType === OFFICEART_BLIP_JPEG || header.recType === OFFICEART_BLIP_JPEG_ALT)
356
+ mime = 'image/jpeg';
357
+ else if (header.recType === OFFICEART_BLIP_TIFF)
358
+ mime = 'image/tiff';
359
+ else if (header.recType === OFFICEART_BLIP_DIB) {
360
+ const bmp = dibToBmp(payload);
361
+ if (bmp) {
362
+ payload = bmp;
363
+ mime = 'image/bmp';
364
+ }
365
+ else {
366
+ mime = 'image/dib';
367
+ }
368
+ }
369
+ return {
370
+ mime,
371
+ bytes: payload,
372
+ displayable: isBrowserDisplayableMime(mime),
373
+ kind: 'officeArt',
374
+ meta: { recType: header.recType, recInstance: header.recInstance },
375
+ };
376
+ }
377
+ if (header.recType === OFFICEART_BLIP_EMF || header.recType === OFFICEART_BLIP_WMF || header.recType === OFFICEART_BLIP_PICT) {
378
+ const infoSize = uidBytes + 34;
379
+ if (atom.length < infoSize)
380
+ return null;
381
+ const metaReader = new BinaryReader(atom);
382
+ const metafileUncompressedSize = metaReader.u32(uidBytes);
383
+ const metafileCompressedSize = metaReader.u32(uidBytes + 28);
384
+ const metafileCompression = atom[uidBytes + 32] ?? 0xff;
385
+ const metafileFilter = atom[uidBytes + 33] ?? 0xff;
386
+ const rawPayload = atom.subarray(infoSize);
387
+ let payload = rawPayload;
388
+ let metafileDecompressed = false;
389
+ let metafileCompressed = metafileCompression !== 0xfe;
390
+ if (metafileCompression === 0x00) {
391
+ const inflated = inflateZlib(rawPayload, metafileUncompressedSize || undefined);
392
+ if (inflated) {
393
+ payload = inflated;
394
+ metafileDecompressed = true;
395
+ metafileCompressed = false;
396
+ }
397
+ }
398
+ const mime = header.recType === OFFICEART_BLIP_EMF ? 'image/emf' : header.recType === OFFICEART_BLIP_WMF ? 'image/wmf' : 'image/pict';
399
+ return {
400
+ mime,
401
+ bytes: payload,
402
+ displayable: isBrowserDisplayableMime(mime),
403
+ kind: 'officeArt',
404
+ meta: {
405
+ recType: header.recType,
406
+ recInstance: header.recInstance,
407
+ metafileUncompressedSize,
408
+ metafileCompressedSize,
409
+ metafileCompression,
410
+ metafileFilter,
411
+ metafileCompressed,
412
+ metafileDecompressed,
413
+ metafileOriginalByteLength: rawPayload.length,
414
+ fbseSize: metafileCompressedSize,
415
+ },
416
+ };
417
+ }
418
+ return null;
419
+ }
420
+ function collectOfficeArtCandidates(bytes, offset, end, out) {
421
+ let cursor = offset;
422
+ while (cursor + 8 <= end) {
423
+ const header = parseOfficeArtRecordHeader(bytes, cursor);
424
+ if (!header)
425
+ break;
426
+ const next = cursor + header.size;
427
+ if (next > end)
428
+ break;
429
+ if (header.recVer === OFFICEART_CONTAINER) {
430
+ collectOfficeArtCandidates(bytes, cursor + 8, next, out);
431
+ }
432
+ else if (header.recType === OFFICEART_FBSE) {
433
+ const candidate = extractEmbeddedBlipFromFbse(bytes, cursor, header);
434
+ if (candidate)
435
+ out.push(candidate);
436
+ }
437
+ else {
438
+ const candidate = extractBlipPayload(bytes, cursor, header);
439
+ if (candidate)
440
+ out.push(candidate);
441
+ }
442
+ cursor = next;
443
+ }
444
+ }
445
+ /**
446
+ * The `picture` field of PICFAndOfficeArtData is an OfficeArtInlineSpContainer.
447
+ * We recursively walk nested OfficeArt containers until we reach actual BLIP atoms
448
+ * (PNG/JPEG/DIB/TIFF/EMF/WMF/PICT) and then extract the payload according to the
449
+ * BLIP record-specific layout from MS-ODRAW.
450
+ */
451
+ function findOfficeArtCandidates(pictureChunk, startOffset) {
452
+ if (startOffset < 0 || startOffset + 8 > pictureChunk.length)
453
+ return [];
454
+ const candidates = [];
455
+ collectOfficeArtCandidates(pictureChunk, startOffset, pictureChunk.length, candidates);
456
+ return candidates;
457
+ }
458
+ function rankPictureCandidate(candidate) {
459
+ switch (candidate.mime) {
460
+ case 'image/png': return 100;
461
+ case 'image/jpeg': return 90;
462
+ case 'image/gif': return 80;
463
+ case 'image/bmp': return 70;
464
+ case 'image/tiff': return 60;
465
+ case 'image/svg+xml': return 50;
466
+ case 'image/emf': return 40;
467
+ case 'image/wmf': return 30;
468
+ case 'image/pict': return 20;
469
+ default: return 0;
470
+ }
471
+ }
472
+ function pickBestPictureCandidate(candidates) {
473
+ let best = null;
474
+ for (const candidate of candidates) {
475
+ if (!best || rankPictureCandidate(candidate) > rankPictureCandidate(best))
476
+ best = candidate;
477
+ }
478
+ return best;
479
+ }
480
+ function maybeConvertVectorCandidate(candidate) {
481
+ if (!/^(?:image\/emf|image\/wmf)$/i.test(candidate.mime))
482
+ return candidate;
483
+ if (!candidate.bytes.length)
484
+ return candidate;
485
+ if (candidate.meta?.metafileCompressed)
486
+ return candidate;
487
+ const converted = convertMetafileToSvg(candidate.mime, candidate.bytes);
488
+ if (!converted)
489
+ return candidate;
490
+ return {
491
+ ...candidate,
492
+ mime: converted.mime,
493
+ bytes: converted.bytes,
494
+ displayable: true,
495
+ meta: {
496
+ ...(candidate.meta || {}),
497
+ vectorConverted: true,
498
+ vectorSourceMime: converted.sourceMime,
499
+ vectorWidth: converted.width,
500
+ vectorHeight: converted.height,
501
+ vectorRecordCount: converted.recordCount,
502
+ },
503
+ };
504
+ }
505
+ function createImageAsset(candidate, pictureOffset, picf, linkedPath, inlineShape) {
506
+ const sourceUrl = candidate.sourceUrl;
507
+ const localExternal = sourceUrl ? isLocalExternalPath(sourceUrl) : false;
508
+ const meta = {
509
+ pictureOffset,
510
+ lcb: picf.lcb,
511
+ cbHeader: picf.cbHeader,
512
+ mm: picf.mm,
513
+ xExt: picf.xExt,
514
+ yExt: picf.yExt,
515
+ dxaGoalTwips: picf.dxaGoalTwips,
516
+ dyaGoalTwips: picf.dyaGoalTwips,
517
+ scaleXPermille: picf.scaleXPermille,
518
+ scaleYPermille: picf.scaleYPermille,
519
+ displayWidthTwips: picf.displayWidthTwips,
520
+ displayHeightTwips: picf.displayHeightTwips,
521
+ linkedPath,
522
+ sourceKind: candidate.kind === 'officeArt' ? 'embedded' : candidate.kind,
523
+ localExternal,
524
+ browserRenderable: candidate.displayable,
525
+ blipIndex: inlineShape?.blipIndex,
526
+ sourceShapeId: inlineShape?.shapeId,
527
+ ...(candidate.meta || {}),
528
+ };
529
+ return {
530
+ id: uniqueId('asset-img'),
531
+ type: 'image',
532
+ mime: candidate.mime,
533
+ bytes: candidate.bytes,
534
+ dataUrl: sourceUrl ? '' : dataUrlFromBytes(candidate.bytes, candidate.mime),
535
+ sourceUrl,
536
+ displayable: candidate.displayable && !localExternal,
537
+ meta,
538
+ };
539
+ }
540
+ /**
541
+ * Resolves a picture character (U+0001 + sprmCPicLocation) to an HTML-friendly asset.
542
+ * The happy path is: PICF -> optional linked picture name -> OfficeArtInlineSpContainer
543
+ * -> OfficeArtBlip*. When no structured BLIP can be found we still fall back to a
544
+ * signature scan so slightly malformed files remain usable.
545
+ */
71
546
  export function extractPictureAsset(dataStreamBytes, pictureOffset, options = {}) {
72
- if (!dataStreamBytes || pictureOffset == null || pictureOffset < 0 || pictureOffset + 68 > dataStreamBytes.length)
547
+ if (!dataStreamBytes || pictureOffset == null || pictureOffset < 0 || pictureOffset + PICF_HEADER_SIZE > dataStreamBytes.length)
73
548
  return null;
74
549
  const reader = new BinaryReader(dataStreamBytes);
75
550
  const lcb = reader.i32(pictureOffset);
76
- const cbHeader = reader.u16(pictureOffset + 4);
77
551
  const total = lcb > 0 && pictureOffset + lcb <= dataStreamBytes.length
78
552
  ? lcb
79
- : Math.min(dataStreamBytes.length - pictureOffset, options.maxPictureBytes || 8 * 1024 * 1024);
553
+ : Math.min(dataStreamBytes.length - pictureOffset, options.maxPictureBytes || DEFAULT_MAX_PICTURE_BYTES);
80
554
  const pictureChunk = dataStreamBytes.subarray(pictureOffset, pictureOffset + total);
81
- const bodyStart = Math.min(cbHeader || 68, pictureChunk.length);
555
+ const picf = parsePicfHeader(pictureChunk);
556
+ if (!picf)
557
+ return null;
558
+ let pictureDataOffset = Math.min(picf.cbHeader || PICF_HEADER_SIZE, pictureChunk.length);
559
+ const pictureName = readOptionalPictureName(pictureChunk, picf);
560
+ const linkedPath = pictureName?.value;
561
+ if (pictureName)
562
+ pictureDataOffset = pictureName.nextOffset;
563
+ const inlineShape = parseInlineOfficeArtShape(pictureChunk, pictureDataOffset);
564
+ const officeArtCandidates = findOfficeArtCandidates(pictureChunk, pictureDataOffset);
565
+ const officeArtCandidate = pickBestPictureCandidate(officeArtCandidates);
566
+ if (officeArtCandidate) {
567
+ return createImageAsset(maybeConvertVectorCandidate(officeArtCandidate), pictureOffset, picf, linkedPath, inlineShape);
568
+ }
569
+ if (linkedPath) {
570
+ const mime = detectMimeFromPath(linkedPath) || 'application/octet-stream';
571
+ const isLocalFileReference = isLocalExternalPath(linkedPath);
572
+ const linkedCandidate = {
573
+ mime,
574
+ bytes: new Uint8Array(0),
575
+ sourceUrl: linkedPath,
576
+ displayable: isBrowserDisplayableMime(mime) && !isLocalFileReference,
577
+ kind: 'linked',
578
+ meta: { linkedPath },
579
+ };
580
+ return createImageAsset(linkedCandidate, pictureOffset, picf, linkedPath, inlineShape);
581
+ }
582
+ const bodyStart = Math.min(picf.cbHeader || PICF_HEADER_SIZE, pictureChunk.length);
82
583
  const segment = detectImageSegment(pictureChunk.subarray(bodyStart));
83
584
  if (!segment) {
84
585
  return {
@@ -87,21 +588,95 @@ export function extractPictureAsset(dataStreamBytes, pictureOffset, options = {}
87
588
  mime: 'application/octet-stream',
88
589
  bytes: pictureChunk,
89
590
  dataUrl: dataUrlFromBytes(pictureChunk, 'application/octet-stream'),
90
- meta: { pictureOffset, lcb, cbHeader },
591
+ displayable: false,
592
+ meta: {
593
+ pictureOffset,
594
+ lcb: picf.lcb,
595
+ cbHeader: picf.cbHeader,
596
+ mm: picf.mm,
597
+ xExt: picf.xExt,
598
+ yExt: picf.yExt,
599
+ dxaGoalTwips: picf.dxaGoalTwips,
600
+ dyaGoalTwips: picf.dyaGoalTwips,
601
+ scaleXPermille: picf.scaleXPermille,
602
+ scaleYPermille: picf.scaleYPermille,
603
+ displayWidthTwips: picf.displayWidthTwips,
604
+ displayHeightTwips: picf.displayHeightTwips,
605
+ sourceKind: 'fallback',
606
+ browserRenderable: false,
607
+ blipIndex: inlineShape?.blipIndex,
608
+ sourceShapeId: inlineShape?.shapeId,
609
+ },
91
610
  };
92
611
  }
93
612
  const start = bodyStart + segment.start;
94
613
  const end = Math.min(bodyStart + segment.end, pictureChunk.length);
95
614
  const imageBytes = pictureChunk.subarray(start, end);
615
+ const fallbackCandidate = maybeConvertVectorCandidate({
616
+ mime: segment.mime,
617
+ bytes: imageBytes,
618
+ displayable: isBrowserDisplayableMime(segment.mime),
619
+ kind: 'fallback',
620
+ meta: {
621
+ pictureOffset,
622
+ lcb: picf.lcb,
623
+ cbHeader: picf.cbHeader,
624
+ mm: picf.mm,
625
+ xExt: picf.xExt,
626
+ yExt: picf.yExt,
627
+ dxaGoalTwips: picf.dxaGoalTwips,
628
+ dyaGoalTwips: picf.dyaGoalTwips,
629
+ scaleXPermille: picf.scaleXPermille,
630
+ scaleYPermille: picf.scaleYPermille,
631
+ displayWidthTwips: picf.displayWidthTwips,
632
+ displayHeightTwips: picf.displayHeightTwips,
633
+ sourceKind: 'fallback',
634
+ browserRenderable: isBrowserDisplayableMime(segment.mime),
635
+ },
636
+ });
96
637
  return {
97
638
  id: uniqueId('asset-img'),
98
639
  type: 'image',
99
- mime: segment.mime,
100
- bytes: imageBytes,
101
- dataUrl: dataUrlFromBytes(imageBytes, segment.mime),
102
- meta: { pictureOffset, lcb, cbHeader },
640
+ mime: fallbackCandidate.mime,
641
+ bytes: fallbackCandidate.bytes,
642
+ dataUrl: dataUrlFromBytes(fallbackCandidate.bytes, fallbackCandidate.mime),
643
+ displayable: fallbackCandidate.displayable,
644
+ meta: {
645
+ pictureOffset,
646
+ lcb: picf.lcb,
647
+ cbHeader: picf.cbHeader,
648
+ mm: picf.mm,
649
+ xExt: picf.xExt,
650
+ yExt: picf.yExt,
651
+ dxaGoalTwips: picf.dxaGoalTwips,
652
+ dyaGoalTwips: picf.dyaGoalTwips,
653
+ scaleXPermille: picf.scaleXPermille,
654
+ scaleYPermille: picf.scaleYPermille,
655
+ displayWidthTwips: picf.displayWidthTwips,
656
+ displayHeightTwips: picf.displayHeightTwips,
657
+ sourceKind: 'fallback',
658
+ browserRenderable: Boolean(fallbackCandidate.displayable),
659
+ blipIndex: inlineShape?.blipIndex,
660
+ sourceShapeId: inlineShape?.shapeId,
661
+ ...(fallbackCandidate.meta || {}),
662
+ },
103
663
  };
104
664
  }
665
+ export function extractFbseImageAsset(drawingBytes, offset, header = parseOfficeArtRecordHeader(drawingBytes, offset)) {
666
+ if (!header || header.recType !== OFFICEART_FBSE)
667
+ return null;
668
+ const candidate = extractEmbeddedBlipFromFbse(drawingBytes, offset, header);
669
+ if (!candidate)
670
+ return null;
671
+ const normalized = maybeConvertVectorCandidate(candidate);
672
+ return createImageAsset(normalized, -1, {
673
+ lcb: normalized.bytes.length,
674
+ cbHeader: 0,
675
+ mm: MM_SHAPE,
676
+ xExt: 0,
677
+ yExt: 0,
678
+ });
679
+ }
105
680
  function readCString(bytes, offset) {
106
681
  let end = offset;
107
682
  while (end < bytes.length && bytes[end] !== 0)
@@ -148,14 +723,16 @@ function readObjectStorage(cfb, entry) {
148
723
  const nativeInfo = parseOle10Native(bytes);
149
724
  if (nativeInfo) {
150
725
  const name = nativeInfo.label || nativeInfo.originalPath.split(/[\\/]/).pop() || `${entry.name}.bin`;
726
+ const mime = detectAttachmentMime(name);
727
+ const attachmentMeta = { ...nativeInfo, sourceKind: 'ole10-native' };
151
728
  const attachment = {
152
729
  id: uniqueId('asset-ole'),
153
730
  type: 'attachment',
154
731
  name,
155
- mime: 'application/octet-stream',
732
+ mime,
156
733
  bytes: nativeInfo.bytes,
157
- dataUrl: dataUrlFromBytes(nativeInfo.bytes, 'application/octet-stream'),
158
- meta: nativeInfo,
734
+ dataUrl: dataUrlFromBytes(nativeInfo.bytes, mime),
735
+ meta: attachmentMeta,
159
736
  };
160
737
  info.attachment = attachment;
161
738
  return info;
@@ -163,14 +740,16 @@ function readObjectStorage(cfb, entry) {
163
740
  }
164
741
  if (packageStream) {
165
742
  const bytes = cfb.getStream(packageStream) || new Uint8Array(0);
743
+ const name = `${slugify(entry.name)}.bin`;
744
+ const mime = detectAttachmentMime(name);
166
745
  const attachment = {
167
746
  id: uniqueId('asset-pkg'),
168
747
  type: 'attachment',
169
- name: `${slugify(entry.name)}.bin`,
170
- mime: 'application/octet-stream',
748
+ name,
749
+ mime,
171
750
  bytes,
172
- dataUrl: dataUrlFromBytes(bytes, 'application/octet-stream'),
173
- meta: { stream: packageStream.name },
751
+ dataUrl: dataUrlFromBytes(bytes, mime),
752
+ meta: { stream: packageStream.name, sourceKind: 'package' },
174
753
  };
175
754
  info.attachment = attachment;
176
755
  }