@file-viewer/doc 2.2.5 → 2.2.7

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.
@@ -2,6 +2,7 @@ export declare const DOC_CONTROL: {
2
2
  paragraph: string;
3
3
  cellMark: string;
4
4
  picture: string;
5
+ textBoxPlaceholder: string;
5
6
  fieldStart: string;
6
7
  fieldSeparator: string;
7
8
  fieldEnd: string;
@@ -2,6 +2,7 @@ export const DOC_CONTROL = {
2
2
  paragraph: '\u000d',
3
3
  cellMark: '\u0007',
4
4
  picture: '\u0001',
5
+ textBoxPlaceholder: '\u0008',
5
6
  fieldStart: '\u0013',
6
7
  fieldSeparator: '\u0014',
7
8
  fieldEnd: '\u0015',
@@ -0,0 +1,3 @@
1
+ import type { MsDocParseResult } from '../types.js';
2
+ export declare function isHtmlWordDocumentStream(bytes: Uint8Array): boolean;
3
+ export declare function parseHtmlWordDocumentStream(bytes: Uint8Array): MsDocParseResult | null;
@@ -0,0 +1,178 @@
1
+ import { charPropsToState, paraPropsToState } from './properties.js';
2
+ const HTML_PREFIX_RE = /^(?:<!doctype\s+html\b|<html\b|<body\b)/i;
3
+ const BLOCK_RE = /<(div|p|h[1-6]|li|pre|blockquote|tr)\b([^>]*)>([\s\S]*?)<\/\1\s*>/gi;
4
+ const UNSAFE_CONTENT_RE = /<(script|style|noscript|iframe|object|embed|svg)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
5
+ function asciiPreview(bytes, length = 1024) {
6
+ return Array.from(bytes.subarray(0, Math.min(length, bytes.length)), byte => byte === 0 ? '' : String.fromCharCode(byte)).join('');
7
+ }
8
+ export function isHtmlWordDocumentStream(bytes) {
9
+ const preview = asciiPreview(bytes)
10
+ .replace(/^\uFEFF/, '')
11
+ .trimStart();
12
+ return HTML_PREFIX_RE.test(preview);
13
+ }
14
+ function decodeWith(bytes, label, fatal = false) {
15
+ try {
16
+ return new TextDecoder(label, { fatal }).decode(bytes);
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ function decodeHtmlWordDocument(bytes) {
23
+ let end = bytes.length;
24
+ while (end > 0 && bytes[end - 1] === 0)
25
+ end -= 1;
26
+ const content = bytes.subarray(0, end);
27
+ const preview = asciiPreview(content, 4096);
28
+ const declared = /charset\s*=\s*["']?([a-z0-9._-]+)/i.exec(preview)?.[1]?.toLowerCase();
29
+ const declaredEncoding = declared === 'gb2312' || declared === 'gbk' ? 'gb18030' : declared;
30
+ if (declaredEncoding) {
31
+ const decoded = decodeWith(content, declaredEncoding);
32
+ if (decoded != null)
33
+ return decoded;
34
+ }
35
+ const utf8 = decodeWith(content, 'utf-8', true);
36
+ if (utf8 != null)
37
+ return utf8;
38
+ return decodeWith(content, 'gb18030') ?? decodeWith(content, 'utf-8') ?? '';
39
+ }
40
+ function decodeEntities(value) {
41
+ const named = {
42
+ amp: '&',
43
+ apos: "'",
44
+ gt: '>',
45
+ lt: '<',
46
+ nbsp: '\u00a0',
47
+ quot: '"',
48
+ };
49
+ return value.replace(/&(#(?:x[0-9a-f]+|\d+)|[a-z][a-z0-9]+);/gi, (match, entity) => {
50
+ if (entity[0] === '#') {
51
+ const hexadecimal = entity[1]?.toLowerCase() === 'x';
52
+ const parsed = Number.parseInt(entity.slice(hexadecimal ? 2 : 1), hexadecimal ? 16 : 10);
53
+ if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 0x10ffff) {
54
+ try {
55
+ return String.fromCodePoint(parsed);
56
+ }
57
+ catch {
58
+ return match;
59
+ }
60
+ }
61
+ return match;
62
+ }
63
+ return named[entity.toLowerCase()] ?? match;
64
+ });
65
+ }
66
+ function fragmentToText(fragment) {
67
+ return decodeEntities(fragment
68
+ .replace(UNSAFE_CONTENT_RE, '')
69
+ .replace(/<!--[\s\S]*?-->/g, '')
70
+ .replace(/<br\s*\/?>/gi, '\n')
71
+ .replace(/<\/(?:div|p|h[1-6]|li|pre|blockquote|tr|table)>/gi, '\n')
72
+ .replace(/<\/?(?:td|th)\b[^>]*>/gi, '\t')
73
+ .replace(/<[^>]+>/g, '')
74
+ .replace(/\u0000/g, ''))
75
+ .replace(/\r\n?/g, '\n')
76
+ .replace(/[\t\f\v ]+/g, ' ')
77
+ .replace(/ *\n */g, '\n')
78
+ .trim();
79
+ }
80
+ function extractParagraphs(html) {
81
+ const safe = html.replace(UNSAFE_CONTENT_RE, '').replace(/<!--[\s\S]*?-->/g, '');
82
+ const paragraphs = [];
83
+ for (const match of safe.matchAll(BLOCK_RE)) {
84
+ const content = match[3] || '';
85
+ const text = fragmentToText(content);
86
+ if (!text)
87
+ continue;
88
+ paragraphs.push({ text, attributes: match[2] || '', content });
89
+ }
90
+ if (paragraphs.length)
91
+ return paragraphs;
92
+ const text = fragmentToText(safe);
93
+ return text ? [{ text, attributes: '', content: safe }] : [];
94
+ }
95
+ function readStyle(attributes) {
96
+ return /\bstyle\s*=\s*(["'])([\s\S]*?)\1/i.exec(attributes)?.[2] || '';
97
+ }
98
+ function createParagraph(source, index) {
99
+ const paraState = paraPropsToState([]);
100
+ const charState = charPropsToState([]);
101
+ const style = readStyle(source.attributes);
102
+ const alignment = /text-align\s*:\s*(center|right|justify)/i.exec(style)?.[1]?.toLowerCase();
103
+ if (alignment === 'center')
104
+ paraState.alignment = 1;
105
+ if (alignment === 'right')
106
+ paraState.alignment = 2;
107
+ if (alignment === 'justify')
108
+ paraState.alignment = 3;
109
+ const indentPoints = Number.parseFloat(/text-indent\s*:\s*([\d.]+)pt/i.exec(style)?.[1] || '');
110
+ if (Number.isFinite(indentPoints))
111
+ paraState.firstLineIndent = Math.round(indentPoints * 20);
112
+ const fontSizePoints = Number.parseFloat(/font-size\s*:\s*([\d.]+)pt/i.exec(style)?.[1] || '');
113
+ if (Number.isFinite(fontSizePoints)) {
114
+ charState.fontSizeHalfPoints = Math.round(Math.min(96, Math.max(8, fontSizePoints)) * 2);
115
+ }
116
+ if (/font-weight\s*:\s*(?:bold|[6-9]00)/i.test(style) ||
117
+ /<(?:b|strong)\b/i.test(source.content)) {
118
+ charState.bold = true;
119
+ }
120
+ if (/font-style\s*:\s*italic/i.test(style) || /<(?:i|em)\b/i.test(source.content)) {
121
+ charState.italic = true;
122
+ }
123
+ const inlines = source.text
124
+ .split('\n')
125
+ .flatMap((line, lineIndex) => [
126
+ ...(lineIndex ? [{ type: 'lineBreak' }] : []),
127
+ ...(line ? [{ type: 'text', text: line, style: { ...charState } }] : []),
128
+ ]);
129
+ return {
130
+ type: 'paragraph',
131
+ id: `html-word-paragraph-${index + 1}`,
132
+ styleId: 0,
133
+ styleName: 'Normal',
134
+ paraState,
135
+ inlines,
136
+ text: source.text,
137
+ };
138
+ }
139
+ export function parseHtmlWordDocumentStream(bytes) {
140
+ if (!isHtmlWordDocumentStream(bytes))
141
+ return null;
142
+ const paragraphs = extractParagraphs(decodeHtmlWordDocument(bytes));
143
+ if (!paragraphs.length)
144
+ return null;
145
+ const blocks = paragraphs.map(createParagraph);
146
+ const textLength = paragraphs.reduce((total, paragraph) => total + paragraph.text.length, 0);
147
+ return {
148
+ kind: 'msdoc',
149
+ version: 1,
150
+ warnings: [
151
+ {
152
+ message: 'Recovered an HTML document stored inside the legacy WordDocument stream.',
153
+ code: 'MSDOC_HTML_WORDDOCUMENT_RECOVERED',
154
+ },
155
+ ],
156
+ meta: {
157
+ fib: {
158
+ wIdent: 0,
159
+ nFib: 0,
160
+ fWhichTblStm: 0,
161
+ fComplex: false,
162
+ fEncrypted: false,
163
+ ccpText: textLength,
164
+ },
165
+ counts: {
166
+ paragraphs: blocks.length,
167
+ blocks: blocks.length,
168
+ assets: 0,
169
+ styles: 0,
170
+ fonts: 0,
171
+ },
172
+ },
173
+ fonts: [],
174
+ styles: [],
175
+ blocks,
176
+ assets: [],
177
+ };
178
+ }
@@ -5,6 +5,7 @@ import { DOC_CONTROL } from './constants.js';
5
5
  import { parseFib } from './fib.js';
6
6
  import { readChpxRuns, readPapxRuns } from './fkp.js';
7
7
  import { parseFonts } from './fonts.js';
8
+ import { parseHtmlWordDocumentStream } from './html-word.js';
8
9
  import { extractObjectPool, extractPictureAsset } from './objects.js';
9
10
  import { applyTableStateToCells, charPropsToState, getTableDepth, paraPropsToState, tablePropsToState, } from './properties.js';
10
11
  import { mergePropertyArrays, parseStyles, splitPropertiesByKind } from './styles.js';
@@ -123,13 +124,68 @@ function normalizePlainTextChar(ch) {
123
124
  case DOC_CONTROL.nonBreakingHyphen:
124
125
  return '-';
125
126
  case DOC_CONTROL.nonRequiredHyphen:
126
- return '';
127
127
  case DOC_CONTROL.annotationRef:
128
+ case DOC_CONTROL.textBoxPlaceholder:
128
129
  return '';
129
130
  default:
130
131
  return ch;
131
132
  }
132
133
  }
134
+ function normalizeParagraphText(text) {
135
+ return text.replaceAll(DOC_CONTROL.textBoxPlaceholder, '');
136
+ }
137
+ function getDocumentStoryRanges(fib, documentLength) {
138
+ const storyLengths = [
139
+ ['main', fib.fibRgLw.ccpText],
140
+ ['footnote', fib.fibRgLw.ccpFtn],
141
+ ['header', fib.fibRgLw.ccpHdd],
142
+ ['macro', fib.fibRgLw.ccpMcr],
143
+ ['annotation', fib.fibRgLw.ccpAtn],
144
+ ['endnote', fib.fibRgLw.ccpEdn],
145
+ ['textbox', fib.fibRgLw.ccpTxbx],
146
+ ['header-textbox', fib.fibRgLw.ccpHdrTxbx],
147
+ ];
148
+ const hasDeclaredStories = storyLengths.some(([, length]) => length > 0);
149
+ if (!hasDeclaredStories)
150
+ return [{ kind: 'main', cpStart: 0, cpEnd: documentLength }];
151
+ const ranges = [];
152
+ let cp = 0;
153
+ for (const [kind, rawLength] of storyLengths) {
154
+ const length = Math.max(0, rawLength || 0);
155
+ const cpStart = Math.min(cp, documentLength);
156
+ cp += length;
157
+ const cpEnd = Math.min(cp, documentLength);
158
+ ranges.push({ kind, cpStart, cpEnd });
159
+ }
160
+ return ranges;
161
+ }
162
+ function buildStoryParagraphRanges(story, documentText, papxRuns) {
163
+ if (story.cpEnd <= story.cpStart)
164
+ return [];
165
+ const runs = papxRuns.filter((run) => run.cpStart < story.cpEnd && run.cpEnd > story.cpStart);
166
+ if (runs.length) {
167
+ return runs.map((run) => {
168
+ const cpStart = Math.max(story.cpStart, run.cpStart);
169
+ const cpEnd = Math.min(story.cpEnd, run.cpEnd);
170
+ return {
171
+ cpStart,
172
+ cpEnd,
173
+ terminator: documentText[cpEnd - 1] || '',
174
+ styleId: run.styleId,
175
+ properties: run.properties,
176
+ storyKind: story.kind,
177
+ };
178
+ }).filter((range) => range.cpEnd > range.cpStart);
179
+ }
180
+ return splitParagraphRanges(documentText.slice(story.cpStart, story.cpEnd)).map((range) => ({
181
+ ...range,
182
+ cpStart: range.cpStart + story.cpStart,
183
+ cpEnd: range.cpEnd + story.cpStart,
184
+ styleId: 0,
185
+ properties: [],
186
+ storyKind: story.kind,
187
+ }));
188
+ }
133
189
  function buildInlineNodes(segments, resolveAsset) {
134
190
  const output = [];
135
191
  const fieldStack = [];
@@ -207,7 +263,7 @@ function buildInlineNodes(segments, resolveAsset) {
207
263
  }
208
264
  return output;
209
265
  }
210
- function buildCharSegments(range, paragraphText, chpxRuns, styles, baseCharProps, resolveFont, cursorRef) {
266
+ function buildCharSegments(range, paragraphText, chpxRuns, styles, baseCharProps, resolveFont, cursorRef, forceReadableColors = false) {
211
267
  const overlaps = getOverlappingRuns(chpxRuns, range.cpStart, range.cpEnd, cursorRef);
212
268
  const boundaries = new Set([range.cpStart, range.cpEnd]);
213
269
  for (const run of overlaps) {
@@ -227,6 +283,8 @@ function buildCharSegments(range, paragraphText, chpxRuns, styles, baseCharProps
227
283
  const charStyleProps = directState.charStyleId != null ? styles.resolveStyle(directState.charStyleId).charProps : [];
228
284
  const finalProps = mergePropertyArrays(baseCharProps, charStyleProps, directProps);
229
285
  const state = charPropsToState(finalProps);
286
+ if (forceReadableColors && state.colorIndex === 8)
287
+ state.colorIndex = 1;
230
288
  const font = resolveFont(state.fontFamilyId);
231
289
  if (font)
232
290
  state.fontFamily = font.name || font.altName || undefined;
@@ -245,6 +303,8 @@ function buildCharSegments(range, paragraphText, chpxRuns, styles, baseCharProps
245
303
  }
246
304
  if (!segments.length && paragraphText) {
247
305
  const state = charPropsToState(baseCharProps);
306
+ if (forceReadableColors && state.colorIndex === 8)
307
+ state.colorIndex = 1;
248
308
  const font = resolveFont(state.fontFamilyId);
249
309
  if (font)
250
310
  state.fontFamily = font.name || font.altName || undefined;
@@ -264,7 +324,7 @@ function buildParagraphModel(range, paragraphText, styles, fonts, chpxRuns, reso
264
324
  const tableState = tablePropsToState(tableProps);
265
325
  const baseCharProps = paraStyle.charProps;
266
326
  const resolveFont = (fontId) => fonts.byIndex(fontId);
267
- const segments = buildCharSegments(range, paragraphText, chpxRuns, styles, baseCharProps, resolveFont, chpxCursor);
327
+ const segments = buildCharSegments(range, paragraphText, chpxRuns, styles, baseCharProps, resolveFont, chpxCursor, range.storyKind === 'textbox' || range.storyKind === 'header-textbox');
268
328
  const inlines = buildInlineNodes(segments, resolveAsset);
269
329
  return {
270
330
  id: uniqueId('para'),
@@ -587,6 +647,11 @@ export function parseMsDoc(input, options = {}) {
587
647
  const wordBytes = cfb.getStream('/WordDocument');
588
648
  if (!wordBytes)
589
649
  throw new Error('Missing WordDocument stream');
650
+ const htmlDocument = parseHtmlWordDocumentStream(wordBytes);
651
+ if (htmlDocument) {
652
+ htmlDocument.warnings.unshift(...(cfb.warnings || []));
653
+ return htmlDocument;
654
+ }
590
655
  const fib = parseFib(wordBytes);
591
656
  if (fib.base.wIdent !== 0xA5EC) {
592
657
  pushWarning(warnings, `Unexpected FIB identifier: 0x${fib.base.wIdent.toString(16)}`);
@@ -601,35 +666,41 @@ export function parseMsDoc(input, options = {}) {
601
666
  const clx = parseClx(tableBytes, fib.fibRgFcLcb);
602
667
  const pieceTexts = buildPieceTextCache(wordBytes, clx);
603
668
  const documentText = pieceTexts.join('');
604
- const mainStoryEnd = fib.fibRgLw.ccpText > 0 ? fib.fibRgLw.ccpText : documentText.length;
669
+ const stories = getDocumentStoryRanges(fib, documentText.length);
670
+ const mainStory = stories.find((story) => story.kind === 'main') || { kind: 'main', cpStart: 0, cpEnd: documentText.length };
671
+ const supplementalTextStories = stories.filter((story) => ((story.kind === 'textbox' || story.kind === 'header-textbox')
672
+ && normalizeParagraphText(documentText.slice(story.cpStart, story.cpEnd)).replace(/[\r\u0007]/g, '').trim().length > 0));
605
673
  const styles = parseStyles(tableBytes, fib.fibRgFcLcb);
606
674
  const fonts = parseFonts(tableBytes, fib.fibRgFcLcb);
607
- const chpxRuns = readChpxRuns(wordBytes, tableBytes, fib, clx).filter((run) => run.cpStart < mainStoryEnd);
675
+ const relevantStoryEnd = Math.max(mainStory.cpEnd, ...supplementalTextStories.map((story) => story.cpEnd));
676
+ const chpxRuns = readChpxRuns(wordBytes, tableBytes, fib, clx)
677
+ .filter((run) => run.cpStart < relevantStoryEnd)
678
+ .map((run) => ({ ...run, cpEnd: Math.min(run.cpEnd, relevantStoryEnd) }));
608
679
  const papxRuns = readPapxRuns(wordBytes, tableBytes, fib, clx, dataBytes)
609
- .filter((run) => run.cpStart < mainStoryEnd)
610
- .map((run) => ({ ...run, cpEnd: Math.min(run.cpEnd, mainStoryEnd) }));
611
- const ranges = papxRuns.length
612
- ? papxRuns.map((run) => ({
613
- cpStart: run.cpStart,
614
- cpEnd: run.cpEnd,
615
- terminator: documentText[run.cpEnd - 1] || '',
616
- styleId: run.styleId,
617
- properties: run.properties,
618
- }))
619
- : splitParagraphRanges(documentText.slice(0, mainStoryEnd)).map((range) => ({ ...range, styleId: 0, properties: [] }));
680
+ .filter((run) => run.cpStart < relevantStoryEnd)
681
+ .map((run) => ({ ...run, cpEnd: Math.min(run.cpEnd, relevantStoryEnd) }));
682
+ const ranges = [mainStory, ...supplementalTextStories]
683
+ .flatMap((story) => buildStoryParagraphRanges(story, documentText, papxRuns));
684
+ if (supplementalTextStories.length) {
685
+ pushWarning(warnings, 'Recovered text from a legacy Word text-box story; text-box positioning is unavailable, so the content was linearized after the main story.', { code: 'MSDOC_TEXTBOX_STORY_LINEARIZED', storyCount: supplementalTextStories.length });
686
+ }
620
687
  const objectPool = extractObjectPool(cfb);
621
688
  const assets = [];
622
689
  const usedAttachmentNames = new Set();
623
690
  const assetCache = new Map();
624
691
  const resolveAsset = createAssetResolver(dataBytes, objectPool, assets, usedAttachmentNames, assetCache, options);
625
692
  const chpxCursor = { index: 0 };
626
- const paragraphs = ranges.map((range) => {
693
+ const paragraphs = ranges.flatMap((range) => {
627
694
  const rawParagraphText = getTextByCp(wordBytes, clx, pieceTexts, range.cpStart, range.cpEnd);
628
695
  const terminator = range.terminator === DOC_CONTROL.paragraph || range.terminator === DOC_CONTROL.cellMark ? range.terminator : '';
629
- const paragraphText = terminator && rawParagraphText.endsWith(terminator)
696
+ const rawContent = terminator && rawParagraphText.endsWith(terminator)
630
697
  ? rawParagraphText.slice(0, -1)
631
698
  : rawParagraphText;
632
- return buildParagraphModel({ ...range, terminator }, paragraphText, styles, fonts, chpxRuns, resolveAsset, chpxCursor);
699
+ const paragraphText = normalizeParagraphText(rawContent);
700
+ const isTextBoxStory = range.storyKind === 'textbox' || range.storyKind === 'header-textbox';
701
+ if ((rawContent.includes(DOC_CONTROL.textBoxPlaceholder) || isTextBoxStory) && !paragraphText.trim())
702
+ return [];
703
+ return [buildParagraphModel({ ...range, terminator }, paragraphText, styles, fonts, chpxRuns, resolveAsset, chpxCursor)];
633
704
  });
634
705
  normalizeRowEndOnlyTables(paragraphs);
635
706
  const blocks = buildBlocks(paragraphs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@file-viewer/doc",
3
- "version": "2.2.5",
3
+ "version": "2.2.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Browser-native legacy MS-DOC (.doc) parser and HTML renderer extracted from the public msdoc-viewer 0.2.0 line.",
@@ -70,6 +70,8 @@
70
70
  "type-check": "tsc -b tsconfig.json",
71
71
  "verify:table-spec": "pnpm build && node scripts/verify-table-spec.mjs",
72
72
  "verify:github-34": "pnpm build && node scripts/verify-github-34.mjs",
73
- "verify:github-87": "pnpm build && node scripts/verify-github-87.mjs"
73
+ "verify:github-87": "pnpm build && node scripts/verify-github-87.mjs",
74
+ "verify:github-171": "pnpm build && node scripts/verify-github-171.mjs",
75
+ "verify:github-192": "pnpm build && node scripts/verify-github-192.mjs"
74
76
  }
75
77
  }