@bendyline/squisq-formats 2.0.1 → 2.1.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.
- package/dist/{chunk-2KPARF2P.js → chunk-74GO3FVS.js} +3 -3
- package/dist/{chunk-WC7WULGV.js → chunk-7ARKUCQT.js} +9 -1
- package/dist/chunk-7ARKUCQT.js.map +1 -0
- package/dist/{chunk-LXYLOOST.js → chunk-DTDF6QDP.js} +3 -3
- package/dist/chunk-DTDF6QDP.js.map +1 -0
- package/dist/{chunk-QRVN6A6E.js → chunk-EW54IRRS.js} +4 -4
- package/dist/{chunk-U32AG3G3.js → chunk-KURGXM4I.js} +2 -2
- package/dist/{chunk-CRAVSMPZ.js → chunk-NNHKUXKA.js} +292 -70
- package/dist/chunk-NNHKUXKA.js.map +1 -0
- package/dist/{chunk-VSYHZECT.js → chunk-QFLDYKCR.js} +2 -2
- package/dist/{chunk-ABVI556T.js → chunk-RLU7UFYU.js} +5 -5
- package/dist/{chunk-XKUMNGBW.js → chunk-SC67HYQJ.js} +2 -2
- package/dist/{chunk-VJJM2SSH.js → chunk-UGYF5AZE.js} +3 -3
- package/dist/{chunk-HTW2M27H.js → chunk-WQSHGBLN.js} +1 -1
- package/dist/docx/index.js +4 -3
- package/dist/{extract-H6RXJMHP.js → extract-OJ7ZQV6P.js} +4 -4
- package/dist/html/index.js +1 -1
- package/dist/index.js +11 -11
- package/dist/infer/index.js +4 -4
- package/dist/{layouts-QVPK3ZCU.js → layouts-5VDIRPIJ.js} +3 -3
- package/dist/ooxml/index.js +3 -3
- package/dist/pptx/index.js +4 -4
- package/dist/registry/index.js +1 -1
- package/dist/xlsx/index.js +3 -3
- package/package.json +2 -2
- package/src/__tests__/docxExport.test.ts +34 -0
- package/src/__tests__/docxImport.test.ts +121 -0
- package/src/__tests__/html.test.ts +4 -0
- package/src/docx/export.ts +113 -5
- package/src/docx/import.ts +324 -69
- package/src/html/htmlTemplate.ts +2 -2
- package/src/ooxml/namespaces.ts +21 -0
- package/dist/chunk-CRAVSMPZ.js.map +0 -1
- package/dist/chunk-LXYLOOST.js.map +0 -1
- package/dist/chunk-WC7WULGV.js.map +0 -1
- /package/dist/{chunk-2KPARF2P.js.map → chunk-74GO3FVS.js.map} +0 -0
- /package/dist/{chunk-QRVN6A6E.js.map → chunk-EW54IRRS.js.map} +0 -0
- /package/dist/{chunk-U32AG3G3.js.map → chunk-KURGXM4I.js.map} +0 -0
- /package/dist/{chunk-VSYHZECT.js.map → chunk-QFLDYKCR.js.map} +0 -0
- /package/dist/{chunk-ABVI556T.js.map → chunk-RLU7UFYU.js.map} +0 -0
- /package/dist/{chunk-XKUMNGBW.js.map → chunk-SC67HYQJ.js.map} +0 -0
- /package/dist/{chunk-VJJM2SSH.js.map → chunk-UGYF5AZE.js.map} +0 -0
- /package/dist/{chunk-HTW2M27H.js.map → chunk-WQSHGBLN.js.map} +0 -0
- /package/dist/{extract-H6RXJMHP.js.map → extract-OJ7ZQV6P.js.map} +0 -0
- /package/dist/{layouts-QVPK3ZCU.js.map → layouts-5VDIRPIJ.js.map} +0 -0
package/src/docx/import.ts
CHANGED
|
@@ -45,12 +45,14 @@ import type {
|
|
|
45
45
|
MarkdownBreak,
|
|
46
46
|
MarkdownFootnoteReference,
|
|
47
47
|
MarkdownFootnoteDefinition,
|
|
48
|
+
MarkdownContainerDirective,
|
|
48
49
|
} from '@bendyline/squisq/markdown';
|
|
49
50
|
|
|
50
51
|
import { openPackage, getPartXml, getPartBinary, getPartRelationships } from '../ooxml/reader.js';
|
|
51
52
|
import type { OoxmlOpenOptions } from '../ooxml/reader.js';
|
|
52
53
|
import type { OoxmlPackage, Relationship } from '../ooxml/types.js';
|
|
53
54
|
import { NS_WML, NS_R } from '../ooxml/namespaces.js';
|
|
55
|
+
import { baseDirOf, resolveTarget } from '../ooxml/readUtils.js';
|
|
54
56
|
import type { ContentContainer } from '@bendyline/squisq/storage';
|
|
55
57
|
import { buildContainer } from '../shared/container.js';
|
|
56
58
|
import { extToMime } from '../shared/images.js';
|
|
@@ -102,7 +104,7 @@ export async function docxToMarkdownDoc(
|
|
|
102
104
|
return { type: 'document', children: [] };
|
|
103
105
|
}
|
|
104
106
|
|
|
105
|
-
const blocks = await
|
|
107
|
+
const blocks = await convertDocumentStories(body, ctx);
|
|
106
108
|
|
|
107
109
|
return { type: 'document', children: blocks };
|
|
108
110
|
}
|
|
@@ -148,7 +150,7 @@ export async function docxToContainer(
|
|
|
148
150
|
const body = getFirstElement(documentXml, 'body');
|
|
149
151
|
if (!body) return buildContainer('', []);
|
|
150
152
|
|
|
151
|
-
const blocks = await
|
|
153
|
+
const blocks = await convertDocumentStories(body, ctx);
|
|
152
154
|
const markdownDoc: MarkdownDocument = { type: 'document', children: blocks };
|
|
153
155
|
|
|
154
156
|
return buildContainer(stringifyMarkdown(markdownDoc), ctx.extractedImages);
|
|
@@ -173,6 +175,10 @@ interface ImportContext {
|
|
|
173
175
|
numbering: Map<string, NumberingInfo>;
|
|
174
176
|
/** Footnote bodies: footnoteId → Element */
|
|
175
177
|
footnotes: Map<string, Element>;
|
|
178
|
+
/** Endnote bodies: endnoteId → Element */
|
|
179
|
+
endnotes: Map<string, Element>;
|
|
180
|
+
/** Part whose relationships are active while converting runs. */
|
|
181
|
+
currentPartPath: string;
|
|
176
182
|
/** Reference to the OOXML package (for extracting images) */
|
|
177
183
|
pkg: OoxmlPackage;
|
|
178
184
|
/** Import options */
|
|
@@ -199,6 +205,8 @@ async function buildImportContext(
|
|
|
199
205
|
documentRels: new Map(),
|
|
200
206
|
numbering: new Map(),
|
|
201
207
|
footnotes: new Map(),
|
|
208
|
+
endnotes: new Map(),
|
|
209
|
+
currentPartPath: 'word/document.xml',
|
|
202
210
|
pkg,
|
|
203
211
|
options,
|
|
204
212
|
extractedImages: new Map(),
|
|
@@ -234,6 +242,9 @@ async function buildImportContext(
|
|
|
234
242
|
// Parse footnotes.xml
|
|
235
243
|
await parseFootnotes(pkg, ctx);
|
|
236
244
|
|
|
245
|
+
// Parse endnotes.xml
|
|
246
|
+
await parseEndnotes(pkg, ctx);
|
|
247
|
+
|
|
237
248
|
return ctx;
|
|
238
249
|
}
|
|
239
250
|
|
|
@@ -351,13 +362,97 @@ async function parseFootnotes(pkg: OoxmlPackage, ctx: ImportContext): Promise<vo
|
|
|
351
362
|
}
|
|
352
363
|
}
|
|
353
364
|
|
|
365
|
+
async function parseEndnotes(pkg: OoxmlPackage, ctx: ImportContext): Promise<void> {
|
|
366
|
+
const doc = await getPartXml(pkg, 'word/endnotes.xml');
|
|
367
|
+
if (!doc) return;
|
|
368
|
+
|
|
369
|
+
const endnoteEls = getAllElements(doc, 'endnote');
|
|
370
|
+
for (const note of endnoteEls) {
|
|
371
|
+
const id = getAttr(note, 'id');
|
|
372
|
+
const type = getAttr(note, 'type');
|
|
373
|
+
if (!id || type === 'separator' || type === 'continuationSeparator') continue;
|
|
374
|
+
ctx.endnotes.set(id, note);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
354
378
|
// ============================================
|
|
355
379
|
// Body Conversion
|
|
356
380
|
// ============================================
|
|
357
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Convert the main story plus the related header/footer stories. Header and
|
|
384
|
+
* footer blocks live in named directives so Markdown consumers can distinguish
|
|
385
|
+
* them from body content and the DOCX exporter can put them back in the right
|
|
386
|
+
* OOXML parts.
|
|
387
|
+
*/
|
|
388
|
+
async function convertDocumentStories(
|
|
389
|
+
body: Element,
|
|
390
|
+
ctx: ImportContext,
|
|
391
|
+
): Promise<MarkdownBlockNode[]> {
|
|
392
|
+
const bodyBlocks = await convertBody(body, ctx);
|
|
393
|
+
const headers = await convertRelatedStories(body, ctx, 'header');
|
|
394
|
+
const footers = await convertRelatedStories(body, ctx, 'footer');
|
|
395
|
+
const noteDefinitions = await convertNoteDefinitions(ctx);
|
|
396
|
+
return [...bodyBlocks, ...headers, ...footers, ...noteDefinitions];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function convertRelatedStories(
|
|
400
|
+
body: Element,
|
|
401
|
+
ctx: ImportContext,
|
|
402
|
+
kind: 'header' | 'footer',
|
|
403
|
+
): Promise<MarkdownContainerDirective[]> {
|
|
404
|
+
const relationshipTypeSuffix = `/${kind}`;
|
|
405
|
+
const referenceName = `${kind}Reference`;
|
|
406
|
+
const referenceTypes = new Map<string, string>();
|
|
407
|
+
for (const reference of getAllElements(body, referenceName)) {
|
|
408
|
+
const id = reference.getAttributeNS(NS_R, 'id') ?? reference.getAttribute('r:id');
|
|
409
|
+
if (id) referenceTypes.set(id, getAttr(reference, 'type') ?? 'default');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const documentRelationships = ctx.documentRels;
|
|
413
|
+
const previousPartPath = ctx.currentPartPath;
|
|
414
|
+
const results: MarkdownContainerDirective[] = [];
|
|
415
|
+
|
|
416
|
+
for (const relationship of documentRelationships.values()) {
|
|
417
|
+
if (!relationship.type.endsWith(relationshipTypeSuffix)) continue;
|
|
418
|
+
|
|
419
|
+
const partPath = resolveTarget(baseDirOf('word/document.xml'), relationship.target);
|
|
420
|
+
const part = await getPartXml(ctx.pkg, partPath);
|
|
421
|
+
if (!part?.documentElement) continue;
|
|
422
|
+
|
|
423
|
+
const partRelationships = await getPartRelationships(ctx.pkg, partPath);
|
|
424
|
+
ctx.documentRels = new Map(partRelationships.map((rel) => [rel.id, rel]));
|
|
425
|
+
ctx.currentPartPath = partPath;
|
|
426
|
+
try {
|
|
427
|
+
const children = await convertBody(part.documentElement, ctx);
|
|
428
|
+
if (children.length === 0) continue;
|
|
429
|
+
results.push({
|
|
430
|
+
type: 'containerDirective',
|
|
431
|
+
name: `docx-${kind}`,
|
|
432
|
+
attributes: {
|
|
433
|
+
type: referenceTypes.get(relationship.id) ?? 'default',
|
|
434
|
+
source: partPath,
|
|
435
|
+
},
|
|
436
|
+
children,
|
|
437
|
+
});
|
|
438
|
+
} finally {
|
|
439
|
+
ctx.documentRels = documentRelationships;
|
|
440
|
+
ctx.currentPartPath = previousPartPath;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return results;
|
|
445
|
+
}
|
|
446
|
+
|
|
358
447
|
async function convertBody(body: Element, ctx: ImportContext): Promise<MarkdownBlockNode[]> {
|
|
448
|
+
return convertBlockElements(Array.from(body.children), ctx);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function convertBlockElements(
|
|
452
|
+
children: Element[],
|
|
453
|
+
ctx: ImportContext,
|
|
454
|
+
): Promise<MarkdownBlockNode[]> {
|
|
359
455
|
const result: MarkdownBlockNode[] = [];
|
|
360
|
-
const children = Array.from(body.children);
|
|
361
456
|
|
|
362
457
|
let i = 0;
|
|
363
458
|
while (i < children.length) {
|
|
@@ -384,16 +479,30 @@ async function convertBody(body: Element, ctx: ImportContext): Promise<MarkdownB
|
|
|
384
479
|
const table = await convertTable(el, ctx);
|
|
385
480
|
if (table) result.push(table);
|
|
386
481
|
i++;
|
|
482
|
+
} else if (localName === 'sdt') {
|
|
483
|
+
const content = getFirstChildElement(el, 'sdtContent');
|
|
484
|
+
if (content) result.push(...(await convertBlockElements(Array.from(content.children), ctx)));
|
|
485
|
+
i++;
|
|
486
|
+
} else if (
|
|
487
|
+
localName === 'ins' ||
|
|
488
|
+
localName === 'moveTo' ||
|
|
489
|
+
localName === 'customXml' ||
|
|
490
|
+
localName === 'smartTag' ||
|
|
491
|
+
localName === 'fldSimple'
|
|
492
|
+
) {
|
|
493
|
+
result.push(...(await convertBlockElements(Array.from(el.children), ctx)));
|
|
494
|
+
i++;
|
|
495
|
+
} else if (localName === 'AlternateContent') {
|
|
496
|
+
const selected = getFirstChildElement(el, 'Choice') ?? getFirstChildElement(el, 'Fallback');
|
|
497
|
+
if (selected)
|
|
498
|
+
result.push(...(await convertBlockElements(Array.from(selected.children), ctx)));
|
|
499
|
+
i++;
|
|
387
500
|
} else {
|
|
388
501
|
// Skip unknown elements (sectPr, bookmarkStart, etc.)
|
|
389
502
|
i++;
|
|
390
503
|
}
|
|
391
504
|
}
|
|
392
505
|
|
|
393
|
-
// Append footnote definitions at the end
|
|
394
|
-
const footnoteNodes = await convertFootnoteDefinitions(ctx);
|
|
395
|
-
result.push(...footnoteNodes);
|
|
396
|
-
|
|
397
506
|
return result;
|
|
398
507
|
}
|
|
399
508
|
|
|
@@ -447,9 +556,15 @@ async function convertParagraph(
|
|
|
447
556
|
async function convertRuns(
|
|
448
557
|
paragraphEl: Element,
|
|
449
558
|
ctx: ImportContext,
|
|
559
|
+
): Promise<MarkdownInlineNode[]> {
|
|
560
|
+
return mergeAdjacentText(await convertInlineElements(Array.from(paragraphEl.children), ctx));
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function convertInlineElements(
|
|
564
|
+
children: Element[],
|
|
565
|
+
ctx: ImportContext,
|
|
450
566
|
): Promise<MarkdownInlineNode[]> {
|
|
451
567
|
const result: MarkdownInlineNode[] = [];
|
|
452
|
-
const children = Array.from(paragraphEl.children);
|
|
453
568
|
|
|
454
569
|
for (const child of children) {
|
|
455
570
|
const localName = child.localName;
|
|
@@ -460,11 +575,34 @@ async function convertRuns(
|
|
|
460
575
|
} else if (localName === 'hyperlink') {
|
|
461
576
|
const link = await convertHyperlink(child, ctx);
|
|
462
577
|
if (link) result.push(link);
|
|
578
|
+
} else if (localName === 'sdt') {
|
|
579
|
+
const content = getFirstChildElement(child, 'sdtContent');
|
|
580
|
+
if (content) {
|
|
581
|
+
result.push(...(await convertInlineElements(Array.from(content.children), ctx)));
|
|
582
|
+
}
|
|
583
|
+
} else if (
|
|
584
|
+
localName === 'ins' ||
|
|
585
|
+
localName === 'moveTo' ||
|
|
586
|
+
localName === 'customXml' ||
|
|
587
|
+
localName === 'smartTag' ||
|
|
588
|
+
localName === 'fldSimple'
|
|
589
|
+
) {
|
|
590
|
+
result.push(...(await convertInlineElements(Array.from(child.children), ctx)));
|
|
591
|
+
} else if (localName === 'AlternateContent') {
|
|
592
|
+
// Choice and Fallback represent the same content for different Word
|
|
593
|
+
// versions. Reading both duplicates every text box and legacy image.
|
|
594
|
+
const selected =
|
|
595
|
+
getFirstChildElement(child, 'Choice') ?? getFirstChildElement(child, 'Fallback');
|
|
596
|
+
if (selected) {
|
|
597
|
+
result.push(...(await convertInlineElements(Array.from(selected.children), ctx)));
|
|
598
|
+
}
|
|
599
|
+
} else if (localName === 'drawing' || localName === 'pict') {
|
|
600
|
+
result.push(...(await convertDrawingContent(child, ctx)));
|
|
463
601
|
}
|
|
464
602
|
// Skip pPr, bookmarkStart, bookmarkEnd, etc.
|
|
465
603
|
}
|
|
466
604
|
|
|
467
|
-
return
|
|
605
|
+
return result;
|
|
468
606
|
}
|
|
469
607
|
|
|
470
608
|
async function convertRun(runEl: Element, ctx: ImportContext): Promise<MarkdownInlineNode[]> {
|
|
@@ -494,8 +632,13 @@ async function convertRun(runEl: Element, ctx: ImportContext): Promise<MarkdownI
|
|
|
494
632
|
}
|
|
495
633
|
result.push(node);
|
|
496
634
|
}
|
|
497
|
-
} else if (localName === 'br') {
|
|
635
|
+
} else if (localName === 'br' || localName === 'cr') {
|
|
498
636
|
result.push({ type: 'break' } satisfies MarkdownBreak);
|
|
637
|
+
} else if (localName === 'tab') {
|
|
638
|
+
// Tabs are visible separators in Word. A literal tab is unstable when
|
|
639
|
+
// serialized through Markdown (it may become indentation), so retain the
|
|
640
|
+
// word boundary as a regular space.
|
|
641
|
+
result.push({ type: 'text', value: ' ' } satisfies MarkdownText);
|
|
499
642
|
} else if (localName === 'footnoteReference') {
|
|
500
643
|
const fnId = getAttr(child, 'id');
|
|
501
644
|
if (fnId && fnId !== '0' && fnId !== '-1') {
|
|
@@ -504,15 +647,113 @@ async function convertRun(runEl: Element, ctx: ImportContext): Promise<MarkdownI
|
|
|
504
647
|
identifier: `fn${fnId}`,
|
|
505
648
|
} satisfies MarkdownFootnoteReference);
|
|
506
649
|
}
|
|
507
|
-
} else if (localName === '
|
|
508
|
-
const
|
|
509
|
-
if (
|
|
650
|
+
} else if (localName === 'endnoteReference') {
|
|
651
|
+
const noteId = getAttr(child, 'id');
|
|
652
|
+
if (noteId && noteId !== '0' && noteId !== '-1') {
|
|
653
|
+
result.push({
|
|
654
|
+
type: 'footnoteReference',
|
|
655
|
+
identifier: `endnote${noteId}`,
|
|
656
|
+
} satisfies MarkdownFootnoteReference);
|
|
657
|
+
}
|
|
658
|
+
} else if (localName === 'drawing' || localName === 'pict' || localName === 'object') {
|
|
659
|
+
result.push(...(await convertDrawingContent(child, ctx)));
|
|
660
|
+
} else if (localName === 'AlternateContent') {
|
|
661
|
+
const selected =
|
|
662
|
+
getFirstChildElement(child, 'Choice') ?? getFirstChildElement(child, 'Fallback');
|
|
663
|
+
if (selected) {
|
|
664
|
+
result.push(...(await convertInlineElements(Array.from(selected.children), ctx)));
|
|
665
|
+
}
|
|
510
666
|
}
|
|
511
667
|
}
|
|
512
668
|
|
|
513
669
|
return result;
|
|
514
670
|
}
|
|
515
671
|
|
|
672
|
+
async function convertDrawingContent(
|
|
673
|
+
el: Element,
|
|
674
|
+
ctx: ImportContext,
|
|
675
|
+
): Promise<MarkdownInlineNode[]> {
|
|
676
|
+
const result: MarkdownInlineNode[] = [];
|
|
677
|
+
|
|
678
|
+
// A positioned Word shape may be a text box, an image, or both. Text box
|
|
679
|
+
// paragraphs are nested inside the drawing rather than being paragraph
|
|
680
|
+
// siblings, so the normal body walker never sees them.
|
|
681
|
+
const textBoxes = findDescendants(el, 'txbxContent');
|
|
682
|
+
for (const textBox of textBoxes) {
|
|
683
|
+
const inlines = await flattenContainerToInlines(textBox, ctx);
|
|
684
|
+
appendInlineGroup(result, inlines);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const image = await extractImage(el, ctx);
|
|
688
|
+
if (image) result.push(image);
|
|
689
|
+
if (textBoxes.length > 0 && result.length > 0) {
|
|
690
|
+
// Positioned text boxes are independent visual regions. Several can be
|
|
691
|
+
// anchored in the same otherwise-empty paragraph (for example, labels on
|
|
692
|
+
// a number line); hard boundaries prevent their text from collapsing into
|
|
693
|
+
// one synthetic word during Markdown serialization.
|
|
694
|
+
if (result[0]?.type !== 'break') result.unshift({ type: 'break' } satisfies MarkdownBreak);
|
|
695
|
+
if (result[result.length - 1]?.type !== 'break') {
|
|
696
|
+
result.push({ type: 'break' } satisfies MarkdownBreak);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return result;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async function flattenContainerToInlines(
|
|
703
|
+
container: Element,
|
|
704
|
+
ctx: ImportContext,
|
|
705
|
+
): Promise<MarkdownInlineNode[]> {
|
|
706
|
+
const result: MarkdownInlineNode[] = [];
|
|
707
|
+
|
|
708
|
+
for (const child of Array.from(container.children)) {
|
|
709
|
+
if (child.localName === 'p') {
|
|
710
|
+
appendInlineGroup(result, await convertRuns(child, ctx));
|
|
711
|
+
} else if (child.localName === 'tbl') {
|
|
712
|
+
appendInlineGroup(result, await flattenTableToInlines(child, ctx));
|
|
713
|
+
} else if (child.localName === 'sdt') {
|
|
714
|
+
const content = getFirstChildElement(child, 'sdtContent');
|
|
715
|
+
if (content) appendInlineGroup(result, await flattenContainerToInlines(content, ctx));
|
|
716
|
+
} else if (
|
|
717
|
+
child.localName === 'ins' ||
|
|
718
|
+
child.localName === 'moveTo' ||
|
|
719
|
+
child.localName === 'customXml' ||
|
|
720
|
+
child.localName === 'smartTag' ||
|
|
721
|
+
child.localName === 'fldSimple'
|
|
722
|
+
) {
|
|
723
|
+
appendInlineGroup(result, await flattenContainerToInlines(child, ctx));
|
|
724
|
+
} else if (child.localName === 'AlternateContent') {
|
|
725
|
+
const selected =
|
|
726
|
+
getFirstChildElement(child, 'Choice') ?? getFirstChildElement(child, 'Fallback');
|
|
727
|
+
if (selected) appendInlineGroup(result, await flattenContainerToInlines(selected, ctx));
|
|
728
|
+
} else if (child.localName === 'r' || child.localName === 'hyperlink') {
|
|
729
|
+
appendInlineGroup(result, await convertInlineElements([child], ctx));
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return mergeAdjacentText(result);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async function flattenTableToInlines(
|
|
737
|
+
table: Element,
|
|
738
|
+
ctx: ImportContext,
|
|
739
|
+
): Promise<MarkdownInlineNode[]> {
|
|
740
|
+
const result: MarkdownInlineNode[] = [];
|
|
741
|
+
for (const row of getAllChildElements(table, 'tr')) {
|
|
742
|
+
for (const cell of getAllChildElements(row, 'tc')) {
|
|
743
|
+
appendInlineGroup(result, await flattenContainerToInlines(cell, ctx));
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return mergeAdjacentText(result);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function appendInlineGroup(target: MarkdownInlineNode[], group: MarkdownInlineNode[]): void {
|
|
750
|
+
if (group.length === 0) return;
|
|
751
|
+
if (target.length > 0 && target[target.length - 1]?.type !== 'break') {
|
|
752
|
+
target.push({ type: 'break' } satisfies MarkdownBreak);
|
|
753
|
+
}
|
|
754
|
+
target.push(...group);
|
|
755
|
+
}
|
|
756
|
+
|
|
516
757
|
interface RunFormat {
|
|
517
758
|
bold: boolean;
|
|
518
759
|
italic: boolean;
|
|
@@ -565,12 +806,7 @@ async function convertHyperlink(el: Element, ctx: ImportContext): Promise<Markdo
|
|
|
565
806
|
if (anchor) url = `#${anchor}`;
|
|
566
807
|
}
|
|
567
808
|
|
|
568
|
-
const inlines
|
|
569
|
-
for (const child of Array.from(el.children)) {
|
|
570
|
-
if (child.localName === 'r') {
|
|
571
|
-
inlines.push(...(await convertRun(child, ctx)));
|
|
572
|
-
}
|
|
573
|
-
}
|
|
809
|
+
const inlines = await convertInlineElements(Array.from(el.children), ctx);
|
|
574
810
|
|
|
575
811
|
if (inlines.length === 0) return null;
|
|
576
812
|
|
|
@@ -586,30 +822,26 @@ async function convertHyperlink(el: Element, ctx: ImportContext): Promise<Markdo
|
|
|
586
822
|
// ============================================
|
|
587
823
|
|
|
588
824
|
async function extractImage(el: Element, ctx: ImportContext): Promise<MarkdownImage | null> {
|
|
589
|
-
//
|
|
825
|
+
// DrawingML uses <a:blip r:embed="...">; older Word/VML documents use
|
|
826
|
+
// <v:imagedata r:id="...">. Supporting both also recovers many scanned
|
|
827
|
+
// forms and older templates in the corpus.
|
|
590
828
|
const blip = findDescendant(el, 'blip');
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
}
|
|
829
|
+
const imageData = findDescendant(el, 'imagedata');
|
|
830
|
+
const rId = blip
|
|
831
|
+
? (blip.getAttributeNS(NS_R, 'embed') ?? blip.getAttribute('r:embed'))
|
|
832
|
+
: (imageData?.getAttributeNS(NS_R, 'id') ??
|
|
833
|
+
imageData?.getAttribute('r:id') ??
|
|
834
|
+
imageData?.getAttribute('o:relid'));
|
|
835
|
+
if (!rId) return null;
|
|
599
836
|
|
|
600
837
|
const rel = ctx.documentRels.get(rId);
|
|
601
|
-
if (!rel)
|
|
602
|
-
return { type: 'image', url: '', alt: 'Image' };
|
|
603
|
-
}
|
|
838
|
+
if (!rel || rel.targetMode === 'External') return null;
|
|
604
839
|
|
|
605
|
-
|
|
606
|
-
const target = rel.target.startsWith('/') ? rel.target.slice(1) : `word/${rel.target}`;
|
|
840
|
+
const target = resolveTarget(baseDirOf(ctx.currentPartPath), rel.target);
|
|
607
841
|
|
|
608
842
|
// Extract binary data from the zip
|
|
609
843
|
const data = await getPartBinary(ctx.pkg, target);
|
|
610
|
-
if (!data)
|
|
611
|
-
return { type: 'image', url: '', alt: 'Image' };
|
|
612
|
-
}
|
|
844
|
+
if (!data) return null;
|
|
613
845
|
|
|
614
846
|
// Determine extension and MIME type
|
|
615
847
|
const dot = target.lastIndexOf('.');
|
|
@@ -625,7 +857,13 @@ async function extractImage(el: Element, ctx: ImportContext): Promise<MarkdownIm
|
|
|
625
857
|
|
|
626
858
|
// Try to extract alt text from the drawing's docPr element
|
|
627
859
|
const docPr = findDescendant(el, 'docPr');
|
|
628
|
-
const
|
|
860
|
+
const shape = findDescendant(el, 'shape');
|
|
861
|
+
const alt =
|
|
862
|
+
docPr?.getAttribute('descr') ||
|
|
863
|
+
docPr?.getAttribute('title') ||
|
|
864
|
+
shape?.getAttribute('alt') ||
|
|
865
|
+
shape?.getAttribute('title') ||
|
|
866
|
+
'Image';
|
|
629
867
|
|
|
630
868
|
return {
|
|
631
869
|
type: 'image',
|
|
@@ -644,6 +882,16 @@ function findDescendant(el: Element, localName: string): Element | null {
|
|
|
644
882
|
return null;
|
|
645
883
|
}
|
|
646
884
|
|
|
885
|
+
/** Recursively find every descendant element with the given local name. */
|
|
886
|
+
function findDescendants(el: Element, localName: string): Element[] {
|
|
887
|
+
const results: Element[] = [];
|
|
888
|
+
for (const child of Array.from(el.children)) {
|
|
889
|
+
if (child.localName === localName) results.push(child);
|
|
890
|
+
results.push(...findDescendants(child, localName));
|
|
891
|
+
}
|
|
892
|
+
return results;
|
|
893
|
+
}
|
|
894
|
+
|
|
647
895
|
// ============================================
|
|
648
896
|
// List Collection
|
|
649
897
|
// ============================================
|
|
@@ -700,6 +948,12 @@ async function collectList(
|
|
|
700
948
|
children: nested.items,
|
|
701
949
|
};
|
|
702
950
|
lastItem.children.push(nestedList);
|
|
951
|
+
} else {
|
|
952
|
+
// Some Word producers emit an empty parent-level numbering
|
|
953
|
+
// paragraph before the first real (more deeply indented) item. With
|
|
954
|
+
// no parent item to attach to, retain those items at this level
|
|
955
|
+
// instead of silently discarding the entire nested subtree.
|
|
956
|
+
items.push(...nested.items);
|
|
703
957
|
}
|
|
704
958
|
i += nested.consumed;
|
|
705
959
|
consumed += nested.consumed;
|
|
@@ -827,17 +1081,10 @@ async function convertTableCell(
|
|
|
827
1081
|
ctx: ImportContext,
|
|
828
1082
|
isHeader: boolean,
|
|
829
1083
|
): Promise<MarkdownTableCell> {
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
//
|
|
833
|
-
const
|
|
834
|
-
for (let pi = 0; pi < paragraphs.length; pi++) {
|
|
835
|
-
if (pi > 0) {
|
|
836
|
-
inlines.push({ type: 'break' } as MarkdownBreak);
|
|
837
|
-
}
|
|
838
|
-
const runs = await convertRuns(paragraphs[pi], ctx);
|
|
839
|
-
inlines.push(...runs);
|
|
840
|
-
}
|
|
1084
|
+
// Cells may contain paragraphs, content controls, and recursively nested
|
|
1085
|
+
// tables (a common layout technique in Word forms). Markdown tables cannot
|
|
1086
|
+
// nest, so preserve all cell text in reading order separated by hard breaks.
|
|
1087
|
+
const inlines = await flattenContainerToInlines(tcEl, ctx);
|
|
841
1088
|
|
|
842
1089
|
return {
|
|
843
1090
|
type: 'tableCell',
|
|
@@ -850,32 +1097,40 @@ async function convertTableCell(
|
|
|
850
1097
|
// Footnote Definition Conversion
|
|
851
1098
|
// ============================================
|
|
852
1099
|
|
|
853
|
-
async function
|
|
1100
|
+
async function convertNoteDefinitions(ctx: ImportContext): Promise<MarkdownFootnoteDefinition[]> {
|
|
1101
|
+
return [
|
|
1102
|
+
...(await convertNotePartDefinitions(ctx, ctx.footnotes, 'fn', 'word/footnotes.xml')),
|
|
1103
|
+
...(await convertNotePartDefinitions(ctx, ctx.endnotes, 'endnote', 'word/endnotes.xml')),
|
|
1104
|
+
];
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
async function convertNotePartDefinitions(
|
|
854
1108
|
ctx: ImportContext,
|
|
1109
|
+
notes: Map<string, Element>,
|
|
1110
|
+
identifierPrefix: string,
|
|
1111
|
+
partPath: string,
|
|
855
1112
|
): Promise<MarkdownFootnoteDefinition[]> {
|
|
856
1113
|
const results: MarkdownFootnoteDefinition[] = [];
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
1114
|
+
const previousRelationships = ctx.documentRels;
|
|
1115
|
+
const previousPartPath = ctx.currentPartPath;
|
|
1116
|
+
const partRelationships = await getPartRelationships(ctx.pkg, partPath);
|
|
1117
|
+
ctx.documentRels = new Map(partRelationships.map((rel) => [rel.id, rel]));
|
|
1118
|
+
ctx.currentPartPath = partPath;
|
|
1119
|
+
|
|
1120
|
+
try {
|
|
1121
|
+
for (const [id, el] of notes) {
|
|
1122
|
+
const children = await convertBody(el, ctx);
|
|
1123
|
+
if (children.length > 0) {
|
|
1124
|
+
results.push({
|
|
1125
|
+
type: 'footnoteDefinition',
|
|
1126
|
+
identifier: `${identifierPrefix}${id}`,
|
|
1127
|
+
children,
|
|
1128
|
+
});
|
|
869
1129
|
}
|
|
870
1130
|
}
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
type: 'footnoteDefinition',
|
|
875
|
-
identifier: `fn${id}`,
|
|
876
|
-
children,
|
|
877
|
-
});
|
|
878
|
-
}
|
|
1131
|
+
} finally {
|
|
1132
|
+
ctx.documentRels = previousRelationships;
|
|
1133
|
+
ctx.currentPartPath = previousPartPath;
|
|
879
1134
|
}
|
|
880
1135
|
|
|
881
1136
|
return results;
|
package/src/html/htmlTemplate.ts
CHANGED
|
@@ -260,7 +260,7 @@ export function generateInlineHtml(doc: Doc, options: HtmlExportOptions): string
|
|
|
260
260
|
*,*::before,*::after{box-sizing:border-box}
|
|
261
261
|
html,body{margin:0;padding:0;height:100%;background:#1a1a2e;color:#e0e0e0;font-family:system-ui,-apple-system,sans-serif}
|
|
262
262
|
#squisq-root{width:100%;height:100%;display:flex;align-items:center;justify-content:center}
|
|
263
|
-
${mode === 'static' ? '#squisq-root{
|
|
263
|
+
${mode === 'static' ? '#squisq-root{display:block}' : ''}
|
|
264
264
|
</style>
|
|
265
265
|
</head>
|
|
266
266
|
<body>
|
|
@@ -328,7 +328,7 @@ export function generateExternalHtml(
|
|
|
328
328
|
*,*::before,*::after{box-sizing:border-box}
|
|
329
329
|
html,body{margin:0;padding:0;height:100%;background:#1a1a2e;color:#e0e0e0;font-family:system-ui,-apple-system,sans-serif}
|
|
330
330
|
#squisq-root{width:100%;height:100%;display:flex;align-items:center;justify-content:center}
|
|
331
|
-
${mode === 'static' ? '#squisq-root{
|
|
331
|
+
${mode === 'static' ? '#squisq-root{display:block}' : ''}
|
|
332
332
|
</style>
|
|
333
333
|
</head>
|
|
334
334
|
<body>
|
package/src/ooxml/namespaces.ts
CHANGED
|
@@ -59,6 +59,18 @@ export const REL_IMAGE =
|
|
|
59
59
|
export const REL_FOOTNOTES =
|
|
60
60
|
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes';
|
|
61
61
|
|
|
62
|
+
/** Relationship type: Endnotes */
|
|
63
|
+
export const REL_ENDNOTES =
|
|
64
|
+
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes';
|
|
65
|
+
|
|
66
|
+
/** Relationship type: Header */
|
|
67
|
+
export const REL_HEADER =
|
|
68
|
+
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header';
|
|
69
|
+
|
|
70
|
+
/** Relationship type: Footer */
|
|
71
|
+
export const REL_FOOTER =
|
|
72
|
+
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer';
|
|
73
|
+
|
|
62
74
|
/** Relationship type: Theme */
|
|
63
75
|
export const REL_THEME =
|
|
64
76
|
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme';
|
|
@@ -156,6 +168,15 @@ export const CONTENT_TYPE_DOCX_FONT_TABLE =
|
|
|
156
168
|
export const CONTENT_TYPE_DOCX_FOOTNOTES =
|
|
157
169
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml';
|
|
158
170
|
|
|
171
|
+
export const CONTENT_TYPE_DOCX_ENDNOTES =
|
|
172
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml';
|
|
173
|
+
|
|
174
|
+
export const CONTENT_TYPE_DOCX_HEADER =
|
|
175
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml';
|
|
176
|
+
|
|
177
|
+
export const CONTENT_TYPE_DOCX_FOOTER =
|
|
178
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml';
|
|
179
|
+
|
|
159
180
|
export const CONTENT_TYPE_PPTX_PRESENTATION =
|
|
160
181
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml';
|
|
161
182
|
|