docxmlater 11.0.9 → 12.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -13
- package/dist/core/Document.d.ts +28 -2
- package/dist/core/Document.d.ts.map +1 -1
- package/dist/core/Document.js +725 -170
- package/dist/core/Document.js.map +1 -1
- package/dist/core/DocumentGenerator.d.ts +1 -0
- package/dist/core/DocumentGenerator.d.ts.map +1 -1
- package/dist/core/DocumentGenerator.js +77 -25
- package/dist/core/DocumentGenerator.js.map +1 -1
- package/dist/core/DocumentParser.d.ts +7 -3
- package/dist/core/DocumentParser.d.ts.map +1 -1
- package/dist/core/DocumentParser.js +431 -85
- package/dist/core/DocumentParser.js.map +1 -1
- package/dist/core/RelationshipManager.d.ts.map +1 -1
- package/dist/core/RelationshipManager.js +2 -2
- package/dist/core/RelationshipManager.js.map +1 -1
- package/dist/elements/Comment.d.ts +15 -1
- package/dist/elements/Comment.d.ts.map +1 -1
- package/dist/elements/Comment.js +67 -12
- package/dist/elements/Comment.js.map +1 -1
- package/dist/elements/CommentManager.d.ts +4 -0
- package/dist/elements/CommentManager.d.ts.map +1 -1
- package/dist/elements/CommentManager.js +74 -16
- package/dist/elements/CommentManager.js.map +1 -1
- package/dist/elements/EndnoteManager.d.ts.map +1 -1
- package/dist/elements/EndnoteManager.js.map +1 -1
- package/dist/elements/Field.d.ts +10 -1
- package/dist/elements/Field.d.ts.map +1 -1
- package/dist/elements/Field.js +76 -34
- package/dist/elements/Field.js.map +1 -1
- package/dist/elements/FieldHelpers.d.ts +1 -0
- package/dist/elements/FieldHelpers.d.ts.map +1 -1
- package/dist/elements/FieldHelpers.js +8 -4
- package/dist/elements/FieldHelpers.js.map +1 -1
- package/dist/elements/Footer.d.ts +3 -1
- package/dist/elements/Footer.d.ts.map +1 -1
- package/dist/elements/Footer.js +39 -53
- package/dist/elements/Footer.js.map +1 -1
- package/dist/elements/FootnoteManager.d.ts.map +1 -1
- package/dist/elements/FootnoteManager.js.map +1 -1
- package/dist/elements/Header.d.ts +3 -1
- package/dist/elements/Header.d.ts.map +1 -1
- package/dist/elements/Header.js +39 -53
- package/dist/elements/Header.js.map +1 -1
- package/dist/elements/HeaderFooterManager.d.ts +4 -2
- package/dist/elements/HeaderFooterManager.d.ts.map +1 -1
- package/dist/elements/HeaderFooterManager.js +36 -6
- package/dist/elements/HeaderFooterManager.js.map +1 -1
- package/dist/elements/Hyperlink.d.ts +6 -1
- package/dist/elements/Hyperlink.d.ts.map +1 -1
- package/dist/elements/Hyperlink.js +69 -44
- package/dist/elements/Hyperlink.js.map +1 -1
- package/dist/elements/Image.d.ts.map +1 -1
- package/dist/elements/Image.js +33 -6
- package/dist/elements/Image.js.map +1 -1
- package/dist/elements/ImageManager.d.ts +1 -0
- package/dist/elements/ImageManager.d.ts.map +1 -1
- package/dist/elements/ImageManager.js +11 -0
- package/dist/elements/ImageManager.js.map +1 -1
- package/dist/elements/Paragraph.d.ts +3 -0
- package/dist/elements/Paragraph.d.ts.map +1 -1
- package/dist/elements/Paragraph.js +91 -26
- package/dist/elements/Paragraph.js.map +1 -1
- package/dist/elements/Revision.d.ts +4 -0
- package/dist/elements/Revision.d.ts.map +1 -1
- package/dist/elements/Revision.js +369 -29
- package/dist/elements/Revision.js.map +1 -1
- package/dist/elements/Run.d.ts +1 -0
- package/dist/elements/Run.d.ts.map +1 -1
- package/dist/elements/Run.js +24 -4
- package/dist/elements/Run.js.map +1 -1
- package/dist/elements/Section.d.ts.map +1 -1
- package/dist/elements/Section.js +17 -7
- package/dist/elements/Section.js.map +1 -1
- package/dist/elements/Shape.d.ts.map +1 -1
- package/dist/elements/Shape.js +50 -42
- package/dist/elements/Shape.js.map +1 -1
- package/dist/elements/StructuredDocumentTag.d.ts +9 -1
- package/dist/elements/StructuredDocumentTag.d.ts.map +1 -1
- package/dist/elements/StructuredDocumentTag.js +46 -6
- package/dist/elements/StructuredDocumentTag.js.map +1 -1
- package/dist/elements/Table.d.ts +2 -0
- package/dist/elements/Table.d.ts.map +1 -1
- package/dist/elements/Table.js +90 -42
- package/dist/elements/Table.js.map +1 -1
- package/dist/elements/TableCell.d.ts +1 -0
- package/dist/elements/TableCell.d.ts.map +1 -1
- package/dist/elements/TableCell.js +32 -13
- package/dist/elements/TableCell.js.map +1 -1
- package/dist/elements/TableOfContents.d.ts.map +1 -1
- package/dist/elements/TableOfContents.js +5 -7
- package/dist/elements/TableOfContents.js.map +1 -1
- package/dist/elements/TableRow.d.ts +1 -0
- package/dist/elements/TableRow.d.ts.map +1 -1
- package/dist/elements/TableRow.js +16 -1
- package/dist/elements/TableRow.js.map +1 -1
- package/dist/elements/TextBox.d.ts.map +1 -1
- package/dist/elements/TextBox.js +55 -42
- package/dist/elements/TextBox.js.map +1 -1
- package/dist/esm/core/Document.js +726 -171
- package/dist/esm/core/Document.js.map +1 -1
- package/dist/esm/core/DocumentGenerator.js +77 -25
- package/dist/esm/core/DocumentGenerator.js.map +1 -1
- package/dist/esm/core/DocumentParser.js +431 -85
- package/dist/esm/core/DocumentParser.js.map +1 -1
- package/dist/esm/core/RelationshipManager.js +2 -2
- package/dist/esm/core/RelationshipManager.js.map +1 -1
- package/dist/esm/elements/Comment.js +67 -12
- package/dist/esm/elements/Comment.js.map +1 -1
- package/dist/esm/elements/CommentManager.js +74 -16
- package/dist/esm/elements/CommentManager.js.map +1 -1
- package/dist/esm/elements/EndnoteManager.js.map +1 -1
- package/dist/esm/elements/Field.js +77 -35
- package/dist/esm/elements/Field.js.map +1 -1
- package/dist/esm/elements/FieldHelpers.js +7 -4
- package/dist/esm/elements/FieldHelpers.js.map +1 -1
- package/dist/esm/elements/Footer.js +39 -53
- package/dist/esm/elements/Footer.js.map +1 -1
- package/dist/esm/elements/FootnoteManager.js.map +1 -1
- package/dist/esm/elements/Header.js +39 -53
- package/dist/esm/elements/Header.js.map +1 -1
- package/dist/esm/elements/HeaderFooterManager.js +36 -6
- package/dist/esm/elements/HeaderFooterManager.js.map +1 -1
- package/dist/esm/elements/Hyperlink.js +69 -44
- package/dist/esm/elements/Hyperlink.js.map +1 -1
- package/dist/esm/elements/Image.js +33 -6
- package/dist/esm/elements/Image.js.map +1 -1
- package/dist/esm/elements/ImageManager.js +11 -0
- package/dist/esm/elements/ImageManager.js.map +1 -1
- package/dist/esm/elements/Paragraph.js +91 -26
- package/dist/esm/elements/Paragraph.js.map +1 -1
- package/dist/esm/elements/Revision.js +369 -29
- package/dist/esm/elements/Revision.js.map +1 -1
- package/dist/esm/elements/Run.js +24 -4
- package/dist/esm/elements/Run.js.map +1 -1
- package/dist/esm/elements/Section.js +17 -7
- package/dist/esm/elements/Section.js.map +1 -1
- package/dist/esm/elements/Shape.js +50 -42
- package/dist/esm/elements/Shape.js.map +1 -1
- package/dist/esm/elements/StructuredDocumentTag.js +46 -6
- package/dist/esm/elements/StructuredDocumentTag.js.map +1 -1
- package/dist/esm/elements/Table.js +90 -42
- package/dist/esm/elements/Table.js.map +1 -1
- package/dist/esm/elements/TableCell.js +32 -13
- package/dist/esm/elements/TableCell.js.map +1 -1
- package/dist/esm/elements/TableOfContents.js +5 -7
- package/dist/esm/elements/TableOfContents.js.map +1 -1
- package/dist/esm/elements/TableRow.js +16 -1
- package/dist/esm/elements/TableRow.js.map +1 -1
- package/dist/esm/elements/TextBox.js +55 -42
- package/dist/esm/elements/TextBox.js.map +1 -1
- package/dist/esm/formatting/AbstractNumbering.js +1 -1
- package/dist/esm/formatting/AbstractNumbering.js.map +1 -1
- package/dist/esm/formatting/NumberingLevel.js +8 -6
- package/dist/esm/formatting/NumberingLevel.js.map +1 -1
- package/dist/esm/formatting/NumberingManager.js +16 -1
- package/dist/esm/formatting/NumberingManager.js.map +1 -1
- package/dist/esm/formatting/Style.js +14 -0
- package/dist/esm/formatting/Style.js.map +1 -1
- package/dist/esm/formatting/StylesManager.js +14 -2
- package/dist/esm/formatting/StylesManager.js.map +1 -1
- package/dist/esm/helpers/CleanupHelper.js +103 -26
- package/dist/esm/helpers/CleanupHelper.js.map +1 -1
- package/dist/esm/images/ImageOptimizer.js +52 -4
- package/dist/esm/images/ImageOptimizer.js.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/internal.js +1 -1
- package/dist/esm/internal.js.map +1 -1
- package/dist/esm/processors/CompatibilityUpgrader.js +7 -1
- package/dist/esm/processors/CompatibilityUpgrader.js.map +1 -1
- package/dist/esm/processors/InMemoryRevisionAcceptor.js +134 -13
- package/dist/esm/processors/InMemoryRevisionAcceptor.js.map +1 -1
- package/dist/esm/processors/RevisionWalker.js +58 -8
- package/dist/esm/processors/RevisionWalker.js.map +1 -1
- package/dist/esm/processors/SelectiveRevisionAcceptor.js +116 -17
- package/dist/esm/processors/SelectiveRevisionAcceptor.js.map +1 -1
- package/dist/esm/processors/cnfStyleDecoder.js +7 -7
- package/dist/esm/processors/cnfStyleDecoder.js.map +1 -1
- package/dist/esm/processors/stripTrackedChanges.js +16 -5
- package/dist/esm/processors/stripTrackedChanges.js.map +1 -1
- package/dist/esm/utils/deepClone.js +24 -11
- package/dist/esm/utils/deepClone.js.map +1 -1
- package/dist/esm/utils/units.js +1 -1
- package/dist/esm/utils/units.js.map +1 -1
- package/dist/esm/utils/validation.js +11 -23
- package/dist/esm/utils/validation.js.map +1 -1
- package/dist/esm/utils/xmlSanitization.js +131 -7
- package/dist/esm/utils/xmlSanitization.js.map +1 -1
- package/dist/esm/validation/RevisionAutoFixer.js +69 -55
- package/dist/esm/validation/RevisionAutoFixer.js.map +1 -1
- package/dist/esm/validation/RevisionValidator.js +24 -20
- package/dist/esm/validation/RevisionValidator.js.map +1 -1
- package/dist/esm/xml/XMLBuilder.js +52 -20
- package/dist/esm/xml/XMLBuilder.js.map +1 -1
- package/dist/esm/xml/XMLParser.js +55 -13
- package/dist/esm/xml/XMLParser.js.map +1 -1
- package/dist/esm/zip/ZipHandler.js +37 -13
- package/dist/esm/zip/ZipHandler.js.map +1 -1
- package/dist/esm/zip/ZipReader.js +53 -6
- package/dist/esm/zip/ZipReader.js.map +1 -1
- package/dist/esm/zip/errors.js +7 -0
- package/dist/esm/zip/errors.js.map +1 -1
- package/dist/esm/zip/types.js +4 -0
- package/dist/esm/zip/types.js.map +1 -1
- package/dist/formatting/AbstractNumbering.d.ts.map +1 -1
- package/dist/formatting/AbstractNumbering.js +1 -1
- package/dist/formatting/AbstractNumbering.js.map +1 -1
- package/dist/formatting/NumberingLevel.d.ts +1 -1
- package/dist/formatting/NumberingLevel.d.ts.map +1 -1
- package/dist/formatting/NumberingLevel.js +8 -6
- package/dist/formatting/NumberingLevel.js.map +1 -1
- package/dist/formatting/NumberingManager.d.ts.map +1 -1
- package/dist/formatting/NumberingManager.js +16 -1
- package/dist/formatting/NumberingManager.js.map +1 -1
- package/dist/formatting/Style.d.ts.map +1 -1
- package/dist/formatting/Style.js +14 -0
- package/dist/formatting/Style.js.map +1 -1
- package/dist/formatting/StylesManager.d.ts +2 -0
- package/dist/formatting/StylesManager.d.ts.map +1 -1
- package/dist/formatting/StylesManager.js +14 -2
- package/dist/formatting/StylesManager.js.map +1 -1
- package/dist/helpers/CleanupHelper.d.ts.map +1 -1
- package/dist/helpers/CleanupHelper.js +103 -26
- package/dist/helpers/CleanupHelper.js.map +1 -1
- package/dist/images/ImageOptimizer.d.ts.map +1 -1
- package/dist/images/ImageOptimizer.js +52 -4
- package/dist/images/ImageOptimizer.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +2 -1
- package/dist/internal.js.map +1 -1
- package/dist/processors/CompatibilityUpgrader.d.ts.map +1 -1
- package/dist/processors/CompatibilityUpgrader.js +7 -1
- package/dist/processors/CompatibilityUpgrader.js.map +1 -1
- package/dist/processors/InMemoryRevisionAcceptor.d.ts +1 -0
- package/dist/processors/InMemoryRevisionAcceptor.d.ts.map +1 -1
- package/dist/processors/InMemoryRevisionAcceptor.js +135 -13
- package/dist/processors/InMemoryRevisionAcceptor.js.map +1 -1
- package/dist/processors/RevisionWalker.d.ts +2 -0
- package/dist/processors/RevisionWalker.d.ts.map +1 -1
- package/dist/processors/RevisionWalker.js +58 -8
- package/dist/processors/RevisionWalker.js.map +1 -1
- package/dist/processors/SelectiveRevisionAcceptor.d.ts.map +1 -1
- package/dist/processors/SelectiveRevisionAcceptor.js +116 -17
- package/dist/processors/SelectiveRevisionAcceptor.js.map +1 -1
- package/dist/processors/cnfStyleDecoder.d.ts.map +1 -1
- package/dist/processors/cnfStyleDecoder.js +7 -7
- package/dist/processors/cnfStyleDecoder.js.map +1 -1
- package/dist/processors/stripTrackedChanges.d.ts.map +1 -1
- package/dist/processors/stripTrackedChanges.js +16 -5
- package/dist/processors/stripTrackedChanges.js.map +1 -1
- package/dist/utils/deepClone.d.ts.map +1 -1
- package/dist/utils/deepClone.js +24 -11
- package/dist/utils/deepClone.js.map +1 -1
- package/dist/utils/units.d.ts.map +1 -1
- package/dist/utils/units.js +1 -1
- package/dist/utils/units.js.map +1 -1
- package/dist/utils/validation.d.ts.map +1 -1
- package/dist/utils/validation.js +11 -23
- package/dist/utils/validation.js.map +1 -1
- package/dist/utils/xmlSanitization.d.ts +2 -0
- package/dist/utils/xmlSanitization.d.ts.map +1 -1
- package/dist/utils/xmlSanitization.js +133 -7
- package/dist/utils/xmlSanitization.js.map +1 -1
- package/dist/validation/RevisionAutoFixer.d.ts +6 -2
- package/dist/validation/RevisionAutoFixer.d.ts.map +1 -1
- package/dist/validation/RevisionAutoFixer.js +68 -54
- package/dist/validation/RevisionAutoFixer.js.map +1 -1
- package/dist/validation/RevisionValidator.d.ts +2 -0
- package/dist/validation/RevisionValidator.d.ts.map +1 -1
- package/dist/validation/RevisionValidator.js +26 -21
- package/dist/validation/RevisionValidator.js.map +1 -1
- package/dist/xml/XMLBuilder.d.ts +1 -0
- package/dist/xml/XMLBuilder.d.ts.map +1 -1
- package/dist/xml/XMLBuilder.js +52 -20
- package/dist/xml/XMLBuilder.js.map +1 -1
- package/dist/xml/XMLParser.d.ts +1 -0
- package/dist/xml/XMLParser.d.ts.map +1 -1
- package/dist/xml/XMLParser.js +55 -13
- package/dist/xml/XMLParser.js.map +1 -1
- package/dist/zip/ZipHandler.d.ts.map +1 -1
- package/dist/zip/ZipHandler.js +37 -13
- package/dist/zip/ZipHandler.js.map +1 -1
- package/dist/zip/ZipReader.d.ts.map +1 -1
- package/dist/zip/ZipReader.js +52 -5
- package/dist/zip/ZipReader.js.map +1 -1
- package/dist/zip/errors.d.ts +3 -0
- package/dist/zip/errors.d.ts.map +1 -1
- package/dist/zip/errors.js +9 -1
- package/dist/zip/errors.js.map +1 -1
- package/dist/zip/types.d.ts +4 -0
- package/dist/zip/types.d.ts.map +1 -1
- package/dist/zip/types.js +4 -0
- package/dist/zip/types.js.map +1 -1
- package/package.json +2 -2
- package/src/core/Document.ts +1159 -240
- package/src/core/DocumentGenerator.ts +106 -26
- package/src/core/DocumentParser.ts +692 -120
- package/src/core/RelationshipManager.ts +446 -442
- package/src/elements/Comment.ts +123 -18
- package/src/elements/CommentManager.ts +121 -22
- package/src/elements/EndnoteManager.ts +4 -1
- package/src/elements/Field.ts +134 -43
- package/src/elements/FieldHelpers.ts +20 -4
- package/src/elements/Footer.ts +62 -67
- package/src/elements/FootnoteManager.ts +4 -1
- package/src/elements/Header.ts +62 -67
- package/src/elements/HeaderFooterManager.ts +56 -8
- package/src/elements/Hyperlink.ts +150 -55
- package/src/elements/Image.ts +51 -7
- package/src/elements/ImageManager.ts +24 -0
- package/src/elements/Paragraph.ts +161 -40
- package/src/elements/Revision.ts +446 -47
- package/src/elements/Run.ts +44 -6
- package/src/elements/Section.ts +25 -9
- package/src/elements/Shape.ts +52 -44
- package/src/elements/StructuredDocumentTag.ts +83 -8
- package/src/elements/Table.ts +151 -50
- package/src/elements/TableCell.ts +57 -17
- package/src/elements/TableOfContents.ts +10 -9
- package/src/elements/TableRow.ts +35 -3
- package/src/elements/TextBox.ts +58 -43
- package/src/formatting/AbstractNumbering.ts +4 -2
- package/src/formatting/NumberingLevel.ts +50 -12
- package/src/formatting/NumberingManager.ts +32 -3
- package/src/formatting/Style.ts +14 -0
- package/src/formatting/StylesManager.ts +29 -2
- package/src/helpers/CleanupHelper.ts +122 -30
- package/src/images/ImageOptimizer.ts +64 -5
- package/src/index.ts +7 -0
- package/src/internal.ts +1 -0
- package/src/processors/CompatibilityUpgrader.ts +15 -2
- package/src/processors/InMemoryRevisionAcceptor.ts +276 -17
- package/src/processors/RevisionWalker.ts +83 -7
- package/src/processors/SelectiveRevisionAcceptor.ts +164 -28
- package/src/processors/cnfStyleDecoder.ts +18 -12
- package/src/processors/stripTrackedChanges.ts +47 -10
- package/src/utils/deepClone.ts +48 -14
- package/src/utils/units.ts +3 -1
- package/src/utils/validation.ts +30 -32
- package/src/utils/xmlSanitization.ts +218 -22
- package/src/validation/RevisionAutoFixer.ts +109 -65
- package/src/validation/RevisionValidator.ts +48 -23
- package/src/xml/XMLBuilder.ts +82 -22
- package/src/xml/XMLParser.ts +87 -20
- package/src/zip/ZipHandler.ts +61 -21
- package/src/zip/ZipReader.ts +135 -13
- package/src/zip/errors.ts +15 -0
- package/src/zip/types.ts +44 -2
package/src/core/Document.ts
CHANGED
|
@@ -86,12 +86,13 @@ import { acceptAllRevisions, cleanupRevisionMetadata } from '../processors/accep
|
|
|
86
86
|
import { acceptRevisionsInMemory } from '../processors/InMemoryRevisionAcceptor.js';
|
|
87
87
|
import { stripTrackedChanges } from '../processors/stripTrackedChanges.js';
|
|
88
88
|
import { diffText, diffHasUnchangedParts } from '../utils/textDiff.js';
|
|
89
|
+
import { reorderRunPropertyChildren } from '../utils/xmlSanitization.js';
|
|
89
90
|
import { XMLBuilder } from '../xml/XMLBuilder.js';
|
|
90
91
|
import { XMLParser } from '../xml/XMLParser.js';
|
|
91
92
|
import { DocumentTrackingContext } from '../tracking/DocumentTrackingContext.js';
|
|
92
93
|
import type { TrackingContext } from '../tracking/TrackingContext.js';
|
|
93
94
|
import { ZipHandler } from '../zip/ZipHandler.js';
|
|
94
|
-
import { DOCX_PATHS } from '../zip/types.js';
|
|
95
|
+
import { DOCX_PATHS, SizeLimitOptions } from '../zip/types.js';
|
|
95
96
|
import { DocumentGenerator } from './DocumentGenerator.js';
|
|
96
97
|
import { DocumentIdManager } from './DocumentIdManager.js';
|
|
97
98
|
import { DocumentParser } from './DocumentParser.js';
|
|
@@ -102,7 +103,7 @@ import {
|
|
|
102
103
|
type DocumentEventType,
|
|
103
104
|
} from './DocumentEvents.js';
|
|
104
105
|
import { RelationshipManager } from './RelationshipManager.js';
|
|
105
|
-
import { RelationshipType } from './Relationship.js';
|
|
106
|
+
import { Relationship, RelationshipType } from './Relationship.js';
|
|
106
107
|
import { BodyElement } from './DocumentContent.js';
|
|
107
108
|
import { optimizeImage, ImageOptimizationResult } from '../images/ImageOptimizer.js';
|
|
108
109
|
|
|
@@ -156,7 +157,7 @@ export interface DocumentLoadOptions extends DocumentOptions {
|
|
|
156
157
|
*
|
|
157
158
|
* - 'preserve': Keep tracked changes as-is (may cause corruption if IDs conflict)
|
|
158
159
|
* - 'accept': Accept all changes - removes revision markup, keeps inserted content, removes deleted content (default)
|
|
159
|
-
* - 'strip': Remove all revision markup
|
|
160
|
+
* - 'strip': Remove all revision markup, keeping inserted content and removing deleted content (same resulting text as 'accept')
|
|
160
161
|
*
|
|
161
162
|
* Default: 'accept' (prevents corruption from revision ID conflicts)
|
|
162
163
|
*
|
|
@@ -209,6 +210,68 @@ export interface DocumentLoadOptions extends DocumentOptions {
|
|
|
209
210
|
* ```
|
|
210
211
|
*/
|
|
211
212
|
acceptRevisions?: boolean;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Resource limits applied while reading the DOCX archive.
|
|
216
|
+
*
|
|
217
|
+
* Forwarded to the underlying ZIP reader to guard against availability
|
|
218
|
+
* attacks (zip bombs, entry-count amplification) when loading untrusted
|
|
219
|
+
* input. Defaults are intentionally generous so legitimate documents load
|
|
220
|
+
* unchanged; tighten the fields on {@link SizeLimitOptions} for untrusted
|
|
221
|
+
* sources.
|
|
222
|
+
*
|
|
223
|
+
* @example
|
|
224
|
+
* ```typescript
|
|
225
|
+
* const doc = await Document.load('untrusted.docx', {
|
|
226
|
+
* sizeLimits: { maxTotalUncompressedMB: 100, maxEntryCount: 500 },
|
|
227
|
+
* });
|
|
228
|
+
* ```
|
|
229
|
+
*/
|
|
230
|
+
sizeLimits?: SizeLimitOptions;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Options controlling {@link Document.toMarkdown} conversion fidelity.
|
|
235
|
+
*
|
|
236
|
+
* Markdown cannot natively express every Word construct, so the converter
|
|
237
|
+
* falls back to inline HTML (when {@link htmlFallback} is enabled) for things
|
|
238
|
+
* like underline, super/subscript, highlight and color, and collects
|
|
239
|
+
* footnotes/endnotes as GFM-style references. These options let callers tune
|
|
240
|
+
* how aggressively that extra information is preserved.
|
|
241
|
+
*/
|
|
242
|
+
export interface MarkdownConversionOptions {
|
|
243
|
+
/**
|
|
244
|
+
* Emit inline HTML for character formatting Markdown cannot represent:
|
|
245
|
+
* underline (`<u>`), superscript (`<sup>`), subscript (`<sub>`), highlight
|
|
246
|
+
* (`<mark>`) and text color (`<span style="color:#…">`). Tracked deletions
|
|
247
|
+
* (in `'preserve'` mode) are wrapped in `<del>`. When `false`, that
|
|
248
|
+
* formatting is dropped but the text itself is still emitted. Default: `true`.
|
|
249
|
+
*/
|
|
250
|
+
htmlFallback?: boolean;
|
|
251
|
+
/**
|
|
252
|
+
* Collect footnote/endnote references as GFM footnote markers (`[^fn1]`,
|
|
253
|
+
* `[^en1]`) and append their definitions at the end of the output.
|
|
254
|
+
* Default: `true`.
|
|
255
|
+
*/
|
|
256
|
+
footnotes?: boolean;
|
|
257
|
+
/**
|
|
258
|
+
* Emit inline images as `` where `src` is the image's
|
|
259
|
+
* relationship id (or a placeholder for unsaved documents). When `false`,
|
|
260
|
+
* images are skipped. Default: `true`.
|
|
261
|
+
*/
|
|
262
|
+
images?: boolean;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Internal state threaded through a single Markdown conversion pass.
|
|
267
|
+
* @internal
|
|
268
|
+
*/
|
|
269
|
+
interface MarkdownContext {
|
|
270
|
+
opts: Required<MarkdownConversionOptions>;
|
|
271
|
+
/** Ordered, de-duplicated footnote/endnote definitions to append at the end. */
|
|
272
|
+
notes: { marker: string; text: string }[];
|
|
273
|
+
/** Markers already emitted, to avoid duplicate definitions. */
|
|
274
|
+
noteMarkers: Set<string>;
|
|
212
275
|
}
|
|
213
276
|
|
|
214
277
|
/**
|
|
@@ -249,6 +312,12 @@ export class Document {
|
|
|
249
312
|
() => this.documentIdManager.getNextId(),
|
|
250
313
|
(existingId) => this.documentIdManager.ensureNextIdAbove(existingId)
|
|
251
314
|
);
|
|
315
|
+
// Instance-level comment edits (resolve, setAuthor, addRun, ...) must
|
|
316
|
+
// flip the dirty flag, otherwise saveComments() takes the passthrough
|
|
317
|
+
// branch and writes the original comments.xml, silently dropping them
|
|
318
|
+
this.commentManager.setModifiedNotifier(() => {
|
|
319
|
+
this._commentsModified = true;
|
|
320
|
+
});
|
|
252
321
|
|
|
253
322
|
this.trackingContext = new DocumentTrackingContext(this.revisionManager);
|
|
254
323
|
this.parser = new DocumentParser();
|
|
@@ -324,6 +393,7 @@ export class Document {
|
|
|
324
393
|
private _originalNumberingXml?: string;
|
|
325
394
|
private _originalSettingsXml?: string;
|
|
326
395
|
private _originalAppPropsXml?: string;
|
|
396
|
+
private _originalCorePropsXml?: string;
|
|
327
397
|
private _originalFootnotesXml?: string;
|
|
328
398
|
private _originalEndnotesXml?: string;
|
|
329
399
|
private _originalCommentsXml?: string;
|
|
@@ -352,6 +422,9 @@ export class Document {
|
|
|
352
422
|
// Track whether app properties have been programmatically modified since load
|
|
353
423
|
private _appPropsModified = false;
|
|
354
424
|
|
|
425
|
+
// Track whether core properties have been programmatically modified since load
|
|
426
|
+
private _corePropsModified = false;
|
|
427
|
+
|
|
355
428
|
// Track whether footnotes/endnotes have been programmatically modified since load
|
|
356
429
|
private _footnotesModified = false;
|
|
357
430
|
private _endnotesModified = false;
|
|
@@ -785,7 +858,7 @@ export class Document {
|
|
|
785
858
|
logger.info('Loading document from file', { path: filePath });
|
|
786
859
|
|
|
787
860
|
const zipHandler = new ZipHandler();
|
|
788
|
-
await zipHandler.load(filePath);
|
|
861
|
+
await zipHandler.load(filePath, { sizeLimits: options?.sizeLimits });
|
|
789
862
|
|
|
790
863
|
const doc = await Document.initializeFromZip(zipHandler, options);
|
|
791
864
|
doc._events.emit('afterLoad', { source: 'file', path: filePath });
|
|
@@ -825,7 +898,7 @@ export class Document {
|
|
|
825
898
|
logger.info('Loading document from buffer', { bufferSize: buffer.length });
|
|
826
899
|
|
|
827
900
|
const zipHandler = new ZipHandler();
|
|
828
|
-
await zipHandler.loadFromBuffer(buffer);
|
|
901
|
+
await zipHandler.loadFromBuffer(buffer, { sizeLimits: options?.sizeLimits });
|
|
829
902
|
|
|
830
903
|
const doc = await Document.initializeFromZip(zipHandler, options);
|
|
831
904
|
doc._events.emit('afterLoad', { source: 'buffer' });
|
|
@@ -890,7 +963,10 @@ export class Document {
|
|
|
890
963
|
|
|
891
964
|
const numberingXml = zipHandler.getFileAsString(DOCX_PATHS.NUMBERING);
|
|
892
965
|
if (numberingXml) {
|
|
893
|
-
|
|
966
|
+
// Numbering definitions are written back verbatim for fidelity, so repair
|
|
967
|
+
// any out-of-order w:rPr children here (e.g. w:b/w:bCs appended after
|
|
968
|
+
// w:color/w:sz by some producers) — Word flags such ordering as corrupt.
|
|
969
|
+
doc._originalNumberingXml = reorderRunPropertyChildren(numberingXml);
|
|
894
970
|
}
|
|
895
971
|
|
|
896
972
|
// Preserve original settings.xml and parse managed settings into in-memory state
|
|
@@ -907,6 +983,15 @@ export class Document {
|
|
|
907
983
|
doc._originalAppPropsXml = appPropsXml;
|
|
908
984
|
}
|
|
909
985
|
|
|
986
|
+
// Preserve original core.xml for round-trip fidelity. parseProperties only
|
|
987
|
+
// extracts a fixed subset of OPC core properties; optional elements like
|
|
988
|
+
// cp:lastPrinted, dc:identifier, and cp:version would otherwise be dropped
|
|
989
|
+
// on a plain load→save round-trip.
|
|
990
|
+
const corePropsXml = zipHandler.getFileAsString(DOCX_PATHS.CORE_PROPS);
|
|
991
|
+
if (corePropsXml) {
|
|
992
|
+
doc._originalCorePropsXml = corePropsXml;
|
|
993
|
+
}
|
|
994
|
+
|
|
910
995
|
// Preserve original webSettings.xml for round-trip fidelity (w:divs, flags, etc.)
|
|
911
996
|
const webSettingsXml = zipHandler.getFileAsString(DOCX_PATHS.WEB_SETTINGS);
|
|
912
997
|
if (webSettingsXml) {
|
|
@@ -1306,18 +1391,20 @@ export class Document {
|
|
|
1306
1391
|
|
|
1307
1392
|
// Register headers with HeaderFooterManager (deduplicate by rId)
|
|
1308
1393
|
// Multiple section property types may reference the same rId
|
|
1394
|
+
// The parsed filename (relationship target) is preserved so saveHeaders()
|
|
1395
|
+
// writes content back to the part the relationship actually points at
|
|
1309
1396
|
const registeredHeaderRIds = new Set<string>();
|
|
1310
|
-
for (const { header, relationshipId } of headersFooters.headers) {
|
|
1397
|
+
for (const { header, relationshipId, filename } of headersFooters.headers) {
|
|
1311
1398
|
if (registeredHeaderRIds.has(relationshipId)) continue;
|
|
1312
|
-
this.headerFooterManager.registerHeader(header, relationshipId);
|
|
1399
|
+
this.headerFooterManager.registerHeader(header, relationshipId, filename);
|
|
1313
1400
|
registeredHeaderRIds.add(relationshipId);
|
|
1314
1401
|
}
|
|
1315
1402
|
|
|
1316
1403
|
// Register footers with HeaderFooterManager (deduplicate by rId)
|
|
1317
1404
|
const registeredFooterRIds = new Set<string>();
|
|
1318
|
-
for (const { footer, relationshipId } of headersFooters.footers) {
|
|
1405
|
+
for (const { footer, relationshipId, filename } of headersFooters.footers) {
|
|
1319
1406
|
if (registeredFooterRIds.has(relationshipId)) continue;
|
|
1320
|
-
this.headerFooterManager.registerFooter(footer, relationshipId);
|
|
1407
|
+
this.headerFooterManager.registerFooter(footer, relationshipId, filename);
|
|
1321
1408
|
registeredFooterRIds.add(relationshipId);
|
|
1322
1409
|
}
|
|
1323
1410
|
} catch (headerFooterError) {
|
|
@@ -1413,7 +1500,13 @@ export class Document {
|
|
|
1413
1500
|
}
|
|
1414
1501
|
|
|
1415
1502
|
const parser = new DocumentParser();
|
|
1416
|
-
|
|
1503
|
+
// commentsExtended.xml carries the resolved state (w15:done) keyed by
|
|
1504
|
+
// each comment's last-paragraph paraId — CT_Comment itself declares no
|
|
1505
|
+
// done attribute, so isResolved() needs the companion part
|
|
1506
|
+
const comments = parser.parseCommentsXml(
|
|
1507
|
+
commentsXml,
|
|
1508
|
+
this._originalCommentCompanionFiles.get(DOCX_PATHS.COMMENTS_EXTENDED)
|
|
1509
|
+
);
|
|
1417
1510
|
|
|
1418
1511
|
// Register each comment with its existing ID
|
|
1419
1512
|
for (const comment of comments) {
|
|
@@ -1435,7 +1528,12 @@ export class Document {
|
|
|
1435
1528
|
|
|
1436
1529
|
try {
|
|
1437
1530
|
const parser = new DocumentParser();
|
|
1438
|
-
|
|
1531
|
+
// Hyperlink r:id targets live in the part-scoped rels file per OPC;
|
|
1532
|
+
// resolving them at parse time lets a real footnote edit regenerate
|
|
1533
|
+
// working links instead of dangling r:id references
|
|
1534
|
+
const relsXml = this.zipHandler.getFileAsString('word/_rels/footnotes.xml.rels');
|
|
1535
|
+
const partRels = relsXml ? RelationshipManager.fromXml(relsXml) : undefined;
|
|
1536
|
+
const footnotes = parser.parseFootnotesXml(footnotesXml, partRels);
|
|
1439
1537
|
|
|
1440
1538
|
for (const footnote of footnotes) {
|
|
1441
1539
|
const id = footnote.getId();
|
|
@@ -1461,7 +1559,9 @@ export class Document {
|
|
|
1461
1559
|
|
|
1462
1560
|
try {
|
|
1463
1561
|
const parser = new DocumentParser();
|
|
1464
|
-
const
|
|
1562
|
+
const relsXml = this.zipHandler.getFileAsString('word/_rels/endnotes.xml.rels');
|
|
1563
|
+
const partRels = relsXml ? RelationshipManager.fromXml(relsXml) : undefined;
|
|
1564
|
+
const endnotes = parser.parseEndnotesXml(endnotesXml, partRels);
|
|
1465
1565
|
|
|
1466
1566
|
for (const endnote of endnotes) {
|
|
1467
1567
|
const id = endnote.getId();
|
|
@@ -1647,12 +1747,18 @@ export class Document {
|
|
|
1647
1747
|
// When tracking enabled, bind context and wrap existing content in w:ins revisions
|
|
1648
1748
|
if (this.trackChangesEnabled && this.trackingContext.isEnabled()) {
|
|
1649
1749
|
this.bindTrackingToElement(paragraph);
|
|
1650
|
-
const
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1750
|
+
const author = this.trackingContext.getAuthor();
|
|
1751
|
+
// Each top-level Run/Hyperlink is REPLACED by its insert revision in
|
|
1752
|
+
// place. Wrapping paragraph.getRuns() and appending the revision would
|
|
1753
|
+
// leave the originals live alongside the w:ins copy (text serialized
|
|
1754
|
+
// twice, unremovable by accept/reject) and would re-wrap runs already
|
|
1755
|
+
// inside existing revisions or hyperlinks.
|
|
1756
|
+
for (const item of paragraph.getContent()) {
|
|
1757
|
+
if (item instanceof Run || item instanceof Hyperlink) {
|
|
1758
|
+
const insertion = Revision.createInsertion(author, item);
|
|
1759
|
+
this.trackingContext.getRevisionManager().register(insertion);
|
|
1760
|
+
paragraph.replaceContent(item, [insertion]);
|
|
1761
|
+
}
|
|
1656
1762
|
}
|
|
1657
1763
|
}
|
|
1658
1764
|
|
|
@@ -2307,6 +2413,7 @@ export class Document {
|
|
|
2307
2413
|
*/
|
|
2308
2414
|
setTitle(title: string): this {
|
|
2309
2415
|
this.properties.title = title;
|
|
2416
|
+
this._corePropsModified = true;
|
|
2310
2417
|
return this;
|
|
2311
2418
|
}
|
|
2312
2419
|
|
|
@@ -2317,6 +2424,7 @@ export class Document {
|
|
|
2317
2424
|
*/
|
|
2318
2425
|
setSubject(subject: string): this {
|
|
2319
2426
|
this.properties.subject = subject;
|
|
2427
|
+
this._corePropsModified = true;
|
|
2320
2428
|
return this;
|
|
2321
2429
|
}
|
|
2322
2430
|
|
|
@@ -2327,6 +2435,7 @@ export class Document {
|
|
|
2327
2435
|
*/
|
|
2328
2436
|
setCreator(creator: string): this {
|
|
2329
2437
|
this.properties.creator = creator;
|
|
2438
|
+
this._corePropsModified = true;
|
|
2330
2439
|
return this;
|
|
2331
2440
|
}
|
|
2332
2441
|
|
|
@@ -2351,6 +2460,7 @@ export class Document {
|
|
|
2351
2460
|
*/
|
|
2352
2461
|
setKeywords(keywords: string): this {
|
|
2353
2462
|
this.properties.keywords = keywords;
|
|
2463
|
+
this._corePropsModified = true;
|
|
2354
2464
|
return this;
|
|
2355
2465
|
}
|
|
2356
2466
|
|
|
@@ -2361,6 +2471,7 @@ export class Document {
|
|
|
2361
2471
|
*/
|
|
2362
2472
|
setDescription(description: string): this {
|
|
2363
2473
|
this.properties.description = description;
|
|
2474
|
+
this._corePropsModified = true;
|
|
2364
2475
|
return this;
|
|
2365
2476
|
}
|
|
2366
2477
|
|
|
@@ -2371,6 +2482,7 @@ export class Document {
|
|
|
2371
2482
|
*/
|
|
2372
2483
|
setCategory(category: string): this {
|
|
2373
2484
|
this.properties.category = category;
|
|
2485
|
+
this._corePropsModified = true;
|
|
2374
2486
|
return this;
|
|
2375
2487
|
}
|
|
2376
2488
|
|
|
@@ -2381,6 +2493,7 @@ export class Document {
|
|
|
2381
2493
|
*/
|
|
2382
2494
|
setContentStatus(status: string): this {
|
|
2383
2495
|
this.properties.contentStatus = status;
|
|
2496
|
+
this._corePropsModified = true;
|
|
2384
2497
|
return this;
|
|
2385
2498
|
}
|
|
2386
2499
|
|
|
@@ -2845,7 +2958,7 @@ export class Document {
|
|
|
2845
2958
|
* console.log(doc.toPlainText());
|
|
2846
2959
|
* ```
|
|
2847
2960
|
*/
|
|
2848
|
-
static async loadFromBase64(base64: string, options?:
|
|
2961
|
+
static async loadFromBase64(base64: string, options?: DocumentLoadOptions): Promise<Document> {
|
|
2849
2962
|
const buffer = Buffer.from(base64, 'base64');
|
|
2850
2963
|
return Document.loadFromBuffer(buffer, options);
|
|
2851
2964
|
}
|
|
@@ -3327,6 +3440,7 @@ export class Document {
|
|
|
3327
3440
|
|
|
3328
3441
|
/**
|
|
3329
3442
|
* Updates the core properties with current values
|
|
3443
|
+
* Uses preservation strategy to maintain original metadata when unmodified
|
|
3330
3444
|
*/
|
|
3331
3445
|
private updateCoreProps(): void {
|
|
3332
3446
|
if (
|
|
@@ -3334,8 +3448,65 @@ export class Document {
|
|
|
3334
3448
|
this._removedParts.has('docProps/core.xml')
|
|
3335
3449
|
)
|
|
3336
3450
|
return;
|
|
3337
|
-
|
|
3338
|
-
|
|
3451
|
+
if (this._originalCorePropsXml && !this._corePropsModified) {
|
|
3452
|
+
// Preserve original as-is — no changes to core properties. Keeps
|
|
3453
|
+
// unparsed elements (cp:lastPrinted, dc:identifier, cp:version) intact.
|
|
3454
|
+
return;
|
|
3455
|
+
}
|
|
3456
|
+
if (this._originalCorePropsXml && this._corePropsModified) {
|
|
3457
|
+
// Merge: update only changed fields in original XML
|
|
3458
|
+
const mergedXml = this.mergeCorePropsWithOriginal();
|
|
3459
|
+
this.zipHandler.updateFile(DOCX_PATHS.CORE_PROPS, mergedXml);
|
|
3460
|
+
} else {
|
|
3461
|
+
// New document — generate from scratch
|
|
3462
|
+
const xml = this.generator.generateCoreProps(this.properties);
|
|
3463
|
+
this.zipHandler.updateFile(DOCX_PATHS.CORE_PROPS, xml);
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
/**
|
|
3468
|
+
* Merges modified core properties with the original core.xml.
|
|
3469
|
+
* Updates only fields that have been programmatically changed, preserving
|
|
3470
|
+
* unparsed OPC core properties (cp:lastPrinted, dc:identifier, cp:version,
|
|
3471
|
+
* dcterms:created/modified, etc.) that the parser never extracts.
|
|
3472
|
+
*/
|
|
3473
|
+
private mergeCorePropsWithOriginal(): string {
|
|
3474
|
+
if (!this._originalCorePropsXml) {
|
|
3475
|
+
return this.generator.generateCoreProps(this.properties);
|
|
3476
|
+
}
|
|
3477
|
+
|
|
3478
|
+
let xml = this._originalCorePropsXml;
|
|
3479
|
+
|
|
3480
|
+
// Each parsed core property maps to a single OOXML element. Replace the
|
|
3481
|
+
// existing element's text in place; if the element is absent, insert it
|
|
3482
|
+
// before the closing tag so the new value is not silently dropped.
|
|
3483
|
+
const mergeField = (tag: string, value: string | undefined): void => {
|
|
3484
|
+
if (value === undefined) return;
|
|
3485
|
+
const escaped = XMLBuilder.sanitizeXmlContent(value);
|
|
3486
|
+
const openClose = new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?</${tag}>`);
|
|
3487
|
+
const selfClosing = new RegExp(`<${tag}\\b[^>]*/>`);
|
|
3488
|
+
if (openClose.test(xml)) {
|
|
3489
|
+
xml = xml.replace(openClose, `<${tag}>${escaped}</${tag}>`);
|
|
3490
|
+
} else if (selfClosing.test(xml)) {
|
|
3491
|
+
xml = xml.replace(selfClosing, `<${tag}>${escaped}</${tag}>`);
|
|
3492
|
+
} else {
|
|
3493
|
+
xml = xml.replace(
|
|
3494
|
+
/<\/cp:coreProperties>/,
|
|
3495
|
+
` <${tag}>${escaped}</${tag}>\n</cp:coreProperties>`
|
|
3496
|
+
);
|
|
3497
|
+
}
|
|
3498
|
+
};
|
|
3499
|
+
|
|
3500
|
+
mergeField('dc:title', this.properties.title);
|
|
3501
|
+
mergeField('dc:subject', this.properties.subject);
|
|
3502
|
+
mergeField('dc:creator', this.properties.creator);
|
|
3503
|
+
mergeField('cp:keywords', this.properties.keywords);
|
|
3504
|
+
mergeField('dc:description', this.properties.description);
|
|
3505
|
+
mergeField('cp:category', this.properties.category);
|
|
3506
|
+
mergeField('cp:contentStatus', this.properties.contentStatus);
|
|
3507
|
+
mergeField('dc:language', this.properties.language);
|
|
3508
|
+
|
|
3509
|
+
return xml;
|
|
3339
3510
|
}
|
|
3340
3511
|
|
|
3341
3512
|
/**
|
|
@@ -3379,6 +3550,9 @@ export class Document {
|
|
|
3379
3550
|
xml = xml.replace(/<Company>[^<]*<\/Company>/, `<Company>${escaped}</Company>`);
|
|
3380
3551
|
} else if (xml.includes('<Company/>')) {
|
|
3381
3552
|
xml = xml.replace(/<Company\/>/, `<Company>${escaped}</Company>`);
|
|
3553
|
+
} else {
|
|
3554
|
+
// Tag fully absent: insert so the value is not silently dropped on save
|
|
3555
|
+
xml = xml.replace(/<\/Properties>/, ` <Company>${escaped}</Company>\n</Properties>`);
|
|
3382
3556
|
}
|
|
3383
3557
|
}
|
|
3384
3558
|
|
|
@@ -3390,6 +3564,12 @@ export class Document {
|
|
|
3390
3564
|
/<Application>[^<]*<\/Application>/,
|
|
3391
3565
|
`<Application>${escaped}</Application>`
|
|
3392
3566
|
);
|
|
3567
|
+
} else {
|
|
3568
|
+
// Tag fully absent: insert so the value is not silently dropped on save
|
|
3569
|
+
xml = xml.replace(
|
|
3570
|
+
/<\/Properties>/,
|
|
3571
|
+
` <Application>${escaped}</Application>\n</Properties>`
|
|
3572
|
+
);
|
|
3393
3573
|
}
|
|
3394
3574
|
}
|
|
3395
3575
|
|
|
@@ -3398,6 +3578,9 @@ export class Document {
|
|
|
3398
3578
|
const escaped = XMLBuilder.sanitizeXmlContent(this.properties.appVersion);
|
|
3399
3579
|
if (xml.includes('<AppVersion>')) {
|
|
3400
3580
|
xml = xml.replace(/<AppVersion>[^<]*<\/AppVersion>/, `<AppVersion>${escaped}</AppVersion>`);
|
|
3581
|
+
} else {
|
|
3582
|
+
// Tag fully absent: insert so the value is not silently dropped on save
|
|
3583
|
+
xml = xml.replace(/<\/Properties>/, ` <AppVersion>${escaped}</AppVersion>\n</Properties>`);
|
|
3401
3584
|
}
|
|
3402
3585
|
}
|
|
3403
3586
|
|
|
@@ -3406,6 +3589,9 @@ export class Document {
|
|
|
3406
3589
|
const escaped = XMLBuilder.sanitizeXmlContent(this.properties.manager);
|
|
3407
3590
|
if (xml.includes('<Manager>')) {
|
|
3408
3591
|
xml = xml.replace(/<Manager>[^<]*<\/Manager>/, `<Manager>${escaped}</Manager>`);
|
|
3592
|
+
} else {
|
|
3593
|
+
// Tag fully absent: insert so the value is not silently dropped on save
|
|
3594
|
+
xml = xml.replace(/<\/Properties>/, ` <Manager>${escaped}</Manager>\n</Properties>`);
|
|
3409
3595
|
}
|
|
3410
3596
|
}
|
|
3411
3597
|
|
|
@@ -3592,9 +3778,10 @@ export class Document {
|
|
|
3592
3778
|
* Merges modified styles with the original styles.xml
|
|
3593
3779
|
*
|
|
3594
3780
|
* This preserves all styles from the original document while only updating
|
|
3595
|
-
* styles that have been explicitly modified via addStyle()
|
|
3781
|
+
* styles that have been explicitly modified via addStyle() and stripping
|
|
3782
|
+
* styles that have been removed via removeStyle().
|
|
3596
3783
|
*
|
|
3597
|
-
* @returns Merged XML string with original styles + modified styles
|
|
3784
|
+
* @returns Merged XML string with original styles + modified styles - removed styles
|
|
3598
3785
|
* @private
|
|
3599
3786
|
*/
|
|
3600
3787
|
private mergeStylesWithOriginal(): string {
|
|
@@ -3603,9 +3790,10 @@ export class Document {
|
|
|
3603
3790
|
}
|
|
3604
3791
|
|
|
3605
3792
|
const modifiedStyleIds = this.stylesManager.getModifiedStyleIds();
|
|
3793
|
+
const removedStyleIds = this.stylesManager.getRemovedStyleIds();
|
|
3606
3794
|
|
|
3607
|
-
// If nothing was modified, return original as-is
|
|
3608
|
-
if (modifiedStyleIds.size === 0) {
|
|
3795
|
+
// If nothing was modified or removed, return original as-is
|
|
3796
|
+
if (modifiedStyleIds.size === 0 && removedStyleIds.size === 0) {
|
|
3609
3797
|
return this._originalStylesXml;
|
|
3610
3798
|
}
|
|
3611
3799
|
|
|
@@ -3646,6 +3834,16 @@ export class Document {
|
|
|
3646
3834
|
}
|
|
3647
3835
|
}
|
|
3648
3836
|
|
|
3837
|
+
// Strip removed style definitions — without this, removals never reach
|
|
3838
|
+
// the saved styles.xml (mirrors the numbering merge's removed-ID handling)
|
|
3839
|
+
for (const styleId of removedStyleIds) {
|
|
3840
|
+
const escapedStyleId = styleId.replace(/[.*+?^${}()|[\]\\"]/g, '\\$&');
|
|
3841
|
+
const removedPattern = new RegExp(
|
|
3842
|
+
`\\s*<w:style[^>]*\\sw:styleId="${escapedStyleId}"[^>]*>[\\s\\S]*?</w:style>`
|
|
3843
|
+
);
|
|
3844
|
+
resultXml = resultXml.replace(removedPattern, '');
|
|
3845
|
+
}
|
|
3846
|
+
|
|
3649
3847
|
return resultXml;
|
|
3650
3848
|
}
|
|
3651
3849
|
|
|
@@ -4003,9 +4201,22 @@ export class Document {
|
|
|
4003
4201
|
|
|
4004
4202
|
// w:revisionView (#31 in CT_Settings)
|
|
4005
4203
|
const view = this.revisionViewSettings;
|
|
4006
|
-
// Only emit revisionView if it differs from defaults (all true)
|
|
4007
|
-
|
|
4008
|
-
|
|
4204
|
+
// Only emit revisionView if it differs from defaults (all true). showMarkup
|
|
4205
|
+
// and showComments default true when absent, so a parsed w:markup="0" /
|
|
4206
|
+
// w:comments="0" must also trigger emission or it would be dropped here.
|
|
4207
|
+
if (
|
|
4208
|
+
!view.showInsertionsAndDeletions ||
|
|
4209
|
+
!view.showFormatting ||
|
|
4210
|
+
!view.showInkAnnotations ||
|
|
4211
|
+
view.showMarkup === false ||
|
|
4212
|
+
view.showComments === false
|
|
4213
|
+
) {
|
|
4214
|
+
// CT_TrackChangesView §17.15.1.77: append w:markup / w:comments only when
|
|
4215
|
+
// explicitly false (absent = default true) so the reviewer's default
|
|
4216
|
+
// markup view survives round-trip, mirroring DocumentGenerator.
|
|
4217
|
+
const markupAttr = view.showMarkup === false ? ' w:markup="0"' : '';
|
|
4218
|
+
const commentsAttr = view.showComments === false ? ' w:comments="0"' : '';
|
|
4219
|
+
trackBlock += `\n <w:revisionView w:insDel="${view.showInsertionsAndDeletions ? '1' : '0'}" w:formatting="${view.showFormatting ? '1' : '0'}" w:inkAnnotations="${view.showInkAnnotations ? '1' : '0'}"${markupAttr}${commentsAttr}/>`;
|
|
4009
4220
|
}
|
|
4010
4221
|
|
|
4011
4222
|
// w:trackRevisions (#32)
|
|
@@ -4062,6 +4273,8 @@ export class Document {
|
|
|
4062
4273
|
const prot = this.documentProtection;
|
|
4063
4274
|
const esc = XMLBuilder.escapeXmlAttribute;
|
|
4064
4275
|
let protXml = `\n <w:documentProtection w:edit="${esc(prot.edit)}" w:enforcement="${prot.enforcement ? '1' : '0'}"`;
|
|
4276
|
+
// w:formatting (CT_DocProtect §17.15.1.29) — tri-state, emit explicit 0/1
|
|
4277
|
+
if (prot.formatting !== undefined) protXml += ` w:formatting="${prot.formatting ? '1' : '0'}"`;
|
|
4065
4278
|
if (prot.cryptProviderType) protXml += ` w:cryptProviderType="${esc(prot.cryptProviderType)}"`;
|
|
4066
4279
|
if (prot.cryptAlgorithmClass)
|
|
4067
4280
|
protXml += ` w:cryptAlgorithmClass="${esc(prot.cryptAlgorithmClass)}"`;
|
|
@@ -4072,6 +4285,12 @@ export class Document {
|
|
|
4072
4285
|
if (prot.cryptSpinCount) protXml += ` w:cryptSpinCount="${esc(String(prot.cryptSpinCount))}"`;
|
|
4073
4286
|
if (prot.hash) protXml += ` w:hash="${esc(prot.hash)}"`;
|
|
4074
4287
|
if (prot.salt) protXml += ` w:salt="${esc(prot.salt)}"`;
|
|
4288
|
+
// Modern Word 2013+ crypto attributes (ISO/IEC 29500-4 §13) — without
|
|
4289
|
+
// these a modern password-protected document loses its password material
|
|
4290
|
+
// whenever any settings-modifying API runs before save.
|
|
4291
|
+
if (prot.algorithmName) protXml += ` w:algorithmName="${esc(prot.algorithmName)}"`;
|
|
4292
|
+
if (prot.hashValue) protXml += ` w:hashValue="${esc(prot.hashValue)}"`;
|
|
4293
|
+
if (prot.saltValue) protXml += ` w:saltValue="${esc(prot.saltValue)}"`;
|
|
4075
4294
|
protXml += '/>';
|
|
4076
4295
|
|
|
4077
4296
|
// Insert before w:autoFormatOverride (#36) if exists,
|
|
@@ -5597,7 +5816,10 @@ export class Document {
|
|
|
5597
5816
|
* - numId references inside body-level tracked deletions (w:del blocks
|
|
5598
5817
|
* that were skipped during parsing)
|
|
5599
5818
|
* - Header, footer, footnote, and endnote XML files
|
|
5600
|
-
* -
|
|
5819
|
+
* - Style definitions (w:numPr in a style's pPr), since styles attach
|
|
5820
|
+
* list numbering without any paragraph carrying a direct numId
|
|
5821
|
+
* - Raw XML safety net scan of all relevant ZIP entries, including
|
|
5822
|
+
* styles.xml and comments.xml
|
|
5601
5823
|
*
|
|
5602
5824
|
* A definition is only removed if it appears in NONE of these sources.
|
|
5603
5825
|
*
|
|
@@ -5663,6 +5885,25 @@ export class Document {
|
|
|
5663
5885
|
}
|
|
5664
5886
|
}
|
|
5665
5887
|
|
|
5888
|
+
// 7. Scan style definitions for numPr references — Word attaches multilevel
|
|
5889
|
+
// list numbering to paragraph styles (e.g., Headings, list styles), so a
|
|
5890
|
+
// numId can be in use while no paragraph carries a direct w:numPr
|
|
5891
|
+
for (const style of this.stylesManager.getAllStyles()) {
|
|
5892
|
+
const styleNumId = style.getProperties().numPr?.numId;
|
|
5893
|
+
if (styleNumId !== undefined) {
|
|
5894
|
+
usedNumIds.add(styleNumId);
|
|
5895
|
+
}
|
|
5896
|
+
}
|
|
5897
|
+
|
|
5898
|
+
// 8. Raw safety net for styles.xml and comments.xml — preserved XML can
|
|
5899
|
+
// carry numId references not represented in the in-memory model
|
|
5900
|
+
for (const filePath of [DOCX_PATHS.STYLES, 'word/comments.xml']) {
|
|
5901
|
+
const xml = this.zipHandler.getFileAsString(filePath);
|
|
5902
|
+
if (xml) {
|
|
5903
|
+
this.collectNumIdsFromXml(xml, usedNumIds);
|
|
5904
|
+
}
|
|
5905
|
+
}
|
|
5906
|
+
|
|
5666
5907
|
// Clean up numbering definitions not found in any of the above scans
|
|
5667
5908
|
this.numberingManager.cleanupUnusedNumbering(usedNumIds);
|
|
5668
5909
|
}
|
|
@@ -5868,7 +6109,69 @@ export class Document {
|
|
|
5868
6109
|
}
|
|
5869
6110
|
const registeredSet = new Set(existingRevisions);
|
|
5870
6111
|
|
|
5871
|
-
|
|
6112
|
+
// Preserved raw XML (revision-nested image runs, w:pict/chart passthrough run
|
|
6113
|
+
// content, preserved elements, nested-table passthrough) is emitted verbatim
|
|
6114
|
+
// with its original wp:docPr id, outside the renumbering below. Reassigned ids
|
|
6115
|
+
// must start above every id that survives serialization unchanged, otherwise a
|
|
6116
|
+
// renumbered drawing can duplicate a preserved one (wp:docPr/@id must be
|
|
6117
|
+
// unique per ECMA-376 Part 1 §20.4.2.5).
|
|
6118
|
+
let maxPreservedDocPrId = 0;
|
|
6119
|
+
const collectPreservedDocPrIds = (rawXml: string | undefined): void => {
|
|
6120
|
+
if (!rawXml || !rawXml.includes('wp:docPr')) return;
|
|
6121
|
+
const docPrPattern = /<wp:docPr\b[^>]*\bid="(\d+)"/g;
|
|
6122
|
+
let match: RegExpExecArray | null;
|
|
6123
|
+
while ((match = docPrPattern.exec(rawXml)) !== null) {
|
|
6124
|
+
const id = parseInt(match[1]!, 10);
|
|
6125
|
+
if (id > maxPreservedDocPrId) maxPreservedDocPrId = id;
|
|
6126
|
+
}
|
|
6127
|
+
};
|
|
6128
|
+
const collectFromRun = (run: Run): void => {
|
|
6129
|
+
if (run instanceof ImageRun) {
|
|
6130
|
+
collectPreservedDocPrIds(run.getRawRunXml());
|
|
6131
|
+
return;
|
|
6132
|
+
}
|
|
6133
|
+
for (const contentItem of run.getContent()) {
|
|
6134
|
+
collectPreservedDocPrIds(contentItem.rawXml);
|
|
6135
|
+
}
|
|
6136
|
+
};
|
|
6137
|
+
const collectFromParagraph = (para: Paragraph): void => {
|
|
6138
|
+
for (const item of para.getContent()) {
|
|
6139
|
+
if (item instanceof Revision) {
|
|
6140
|
+
for (const revItem of item.getContent()) {
|
|
6141
|
+
if (revItem instanceof Run) collectFromRun(revItem);
|
|
6142
|
+
}
|
|
6143
|
+
} else if (item instanceof Run) {
|
|
6144
|
+
collectFromRun(item);
|
|
6145
|
+
} else if (item instanceof PreservedElement) {
|
|
6146
|
+
collectPreservedDocPrIds(item.getRawXml());
|
|
6147
|
+
}
|
|
6148
|
+
}
|
|
6149
|
+
};
|
|
6150
|
+
for (const element of this.bodyElements) {
|
|
6151
|
+
if (element instanceof Paragraph) {
|
|
6152
|
+
collectFromParagraph(element);
|
|
6153
|
+
} else if (element instanceof Table) {
|
|
6154
|
+
for (const row of element.getRows()) {
|
|
6155
|
+
for (const cell of row.getCells()) {
|
|
6156
|
+
for (const para of cell.getParagraphs()) {
|
|
6157
|
+
collectFromParagraph(para);
|
|
6158
|
+
}
|
|
6159
|
+
for (const nested of cell.getRawNestedContent()) {
|
|
6160
|
+
collectPreservedDocPrIds(nested.xml);
|
|
6161
|
+
}
|
|
6162
|
+
}
|
|
6163
|
+
}
|
|
6164
|
+
} else {
|
|
6165
|
+
// Raw-XML passthrough body elements (AlternateContent, MathParagraph,
|
|
6166
|
+
// CustomXmlBlock, PreservedElement, registered elements) expose getRawXml().
|
|
6167
|
+
const passthrough = element as { getRawXml?: () => string };
|
|
6168
|
+
if (typeof passthrough.getRawXml === 'function') {
|
|
6169
|
+
collectPreservedDocPrIds(passthrough.getRawXml());
|
|
6170
|
+
}
|
|
6171
|
+
}
|
|
6172
|
+
}
|
|
6173
|
+
|
|
6174
|
+
let docPrId = maxPreservedDocPrId + 1;
|
|
5872
6175
|
|
|
5873
6176
|
// Collect existing paraIds to avoid collisions when generating new ones
|
|
5874
6177
|
const existingParaIds = new Set<string>();
|
|
@@ -5906,6 +6209,16 @@ export class Document {
|
|
|
5906
6209
|
item.setDocPrId(docPrId++);
|
|
5907
6210
|
} else if (item instanceof ImageRun) {
|
|
5908
6211
|
item.getImageElement().setDocPrId(docPrId++);
|
|
6212
|
+
} else if (item instanceof Revision) {
|
|
6213
|
+
// Revision-nested images serialize from the model when no raw run XML
|
|
6214
|
+
// was captured (programmatic) or after a mutation splices a regenerated
|
|
6215
|
+
// w:drawing — give them fresh ids so they cannot collide with the
|
|
6216
|
+
// renumbered top-level drawings. Unmutated raw runs ignore the model id.
|
|
6217
|
+
for (const revItem of item.getContent()) {
|
|
6218
|
+
if (revItem instanceof ImageRun) {
|
|
6219
|
+
revItem.getImageElement().setDocPrId(docPrId++);
|
|
6220
|
+
}
|
|
6221
|
+
}
|
|
5909
6222
|
}
|
|
5910
6223
|
}
|
|
5911
6224
|
|
|
@@ -6631,6 +6944,10 @@ export class Document {
|
|
|
6631
6944
|
numLevel.setHangingIndent(360);
|
|
6632
6945
|
}
|
|
6633
6946
|
|
|
6947
|
+
// Level setters don't propagate dirty state; without this the save
|
|
6948
|
+
// pipeline writes the original numbering.xml verbatim for loaded docs
|
|
6949
|
+
this.numberingManager.markAbstractNumberingModified(abstractNumId);
|
|
6950
|
+
|
|
6634
6951
|
// Apply paragraph formatting to all paragraphs using this list
|
|
6635
6952
|
this.applyFormattingToListParagraphs(instance.getNumId());
|
|
6636
6953
|
count++;
|
|
@@ -6697,6 +7014,10 @@ export class Document {
|
|
|
6697
7014
|
numLevel.setAlignment('left');
|
|
6698
7015
|
}
|
|
6699
7016
|
|
|
7017
|
+
// Level setters don't propagate dirty state; without this the save
|
|
7018
|
+
// pipeline writes the original numbering.xml verbatim for loaded docs
|
|
7019
|
+
this.numberingManager.markAbstractNumberingModified(abstractNumId);
|
|
7020
|
+
|
|
6700
7021
|
// Apply paragraph formatting to all paragraphs using this list
|
|
6701
7022
|
this.applyFormattingToListParagraphs(instance.getNumId());
|
|
6702
7023
|
count++;
|
|
@@ -9778,8 +10099,11 @@ export class Document {
|
|
|
9778
10099
|
* @returns This document for chaining
|
|
9779
10100
|
*/
|
|
9780
10101
|
setHeader(header: Header): this {
|
|
9781
|
-
//
|
|
9782
|
-
|
|
10102
|
+
// The relationship must target the part name the manager assigns,
|
|
10103
|
+
// otherwise saveHeaders() writes content to a part nothing references
|
|
10104
|
+
const relationship = this.relationshipManager.addHeader(
|
|
10105
|
+
this.headerFooterManager.peekHeaderFilename(header)
|
|
10106
|
+
);
|
|
9783
10107
|
|
|
9784
10108
|
// Register with manager
|
|
9785
10109
|
this.headerFooterManager.registerHeader(header, relationship.getId());
|
|
@@ -9799,9 +10123,9 @@ export class Document {
|
|
|
9799
10123
|
// Enable title page
|
|
9800
10124
|
this.section.setTitlePage(true);
|
|
9801
10125
|
|
|
9802
|
-
//
|
|
10126
|
+
// Target the part name the manager will assign on registration
|
|
9803
10127
|
const relationship = this.relationshipManager.addHeader(
|
|
9804
|
-
|
|
10128
|
+
this.headerFooterManager.peekHeaderFilename(header)
|
|
9805
10129
|
);
|
|
9806
10130
|
|
|
9807
10131
|
// Register with manager
|
|
@@ -9819,9 +10143,9 @@ export class Document {
|
|
|
9819
10143
|
* @returns This document for chaining
|
|
9820
10144
|
*/
|
|
9821
10145
|
setEvenPageHeader(header: Header): this {
|
|
9822
|
-
//
|
|
10146
|
+
// Target the part name the manager will assign on registration
|
|
9823
10147
|
const relationship = this.relationshipManager.addHeader(
|
|
9824
|
-
|
|
10148
|
+
this.headerFooterManager.peekHeaderFilename(header)
|
|
9825
10149
|
);
|
|
9826
10150
|
|
|
9827
10151
|
// Register with manager
|
|
@@ -9839,8 +10163,11 @@ export class Document {
|
|
|
9839
10163
|
* @returns This document for chaining
|
|
9840
10164
|
*/
|
|
9841
10165
|
setFooter(footer: Footer): this {
|
|
9842
|
-
//
|
|
9843
|
-
|
|
10166
|
+
// The relationship must target the part name the manager assigns,
|
|
10167
|
+
// otherwise saveFooters() writes content to a part nothing references
|
|
10168
|
+
const relationship = this.relationshipManager.addFooter(
|
|
10169
|
+
this.headerFooterManager.peekFooterFilename(footer)
|
|
10170
|
+
);
|
|
9844
10171
|
|
|
9845
10172
|
// Register with manager
|
|
9846
10173
|
this.headerFooterManager.registerFooter(footer, relationship.getId());
|
|
@@ -9860,9 +10187,9 @@ export class Document {
|
|
|
9860
10187
|
// Enable title page
|
|
9861
10188
|
this.section.setTitlePage(true);
|
|
9862
10189
|
|
|
9863
|
-
//
|
|
10190
|
+
// Target the part name the manager will assign on registration
|
|
9864
10191
|
const relationship = this.relationshipManager.addFooter(
|
|
9865
|
-
|
|
10192
|
+
this.headerFooterManager.peekFooterFilename(footer)
|
|
9866
10193
|
);
|
|
9867
10194
|
|
|
9868
10195
|
// Register with manager
|
|
@@ -9880,9 +10207,9 @@ export class Document {
|
|
|
9880
10207
|
* @returns This document for chaining
|
|
9881
10208
|
*/
|
|
9882
10209
|
setEvenPageFooter(footer: Footer): this {
|
|
9883
|
-
//
|
|
10210
|
+
// Target the part name the manager will assign on registration
|
|
9884
10211
|
const relationship = this.relationshipManager.addFooter(
|
|
9885
|
-
|
|
10212
|
+
this.headerFooterManager.peekFooterFilename(footer)
|
|
9886
10213
|
);
|
|
9887
10214
|
|
|
9888
10215
|
// Register with manager
|
|
@@ -10188,6 +10515,26 @@ export class Document {
|
|
|
10188
10515
|
}
|
|
10189
10516
|
// Remove companion files since regenerated comments lack w14:paraId
|
|
10190
10517
|
this.removeCommentCompanionFiles();
|
|
10518
|
+
|
|
10519
|
+
// Resolved state lives in commentsExtended.xml (w15:commentEx
|
|
10520
|
+
// w15:done) — CT_Comment declares no done attribute, so the part is
|
|
10521
|
+
// regenerated alongside comments.xml whenever a comment is resolved
|
|
10522
|
+
const extendedXml = this.commentManager.generateCommentsExtendedXml();
|
|
10523
|
+
if (extendedXml) {
|
|
10524
|
+
this.zipHandler.addFile(DOCX_PATHS.COMMENTS_EXTENDED, extendedXml);
|
|
10525
|
+
const hasExtendedRel = this.relationshipManager
|
|
10526
|
+
.getAllRelationships()
|
|
10527
|
+
.some((rel) => rel.getTarget() === 'commentsExtended.xml');
|
|
10528
|
+
if (!hasExtendedRel) {
|
|
10529
|
+
this.relationshipManager.addRelationship(
|
|
10530
|
+
Relationship.create({
|
|
10531
|
+
id: this.relationshipManager.generateId(),
|
|
10532
|
+
type: 'http://schemas.microsoft.com/office/2011/relationships/commentsExtended',
|
|
10533
|
+
target: 'commentsExtended.xml',
|
|
10534
|
+
})
|
|
10535
|
+
);
|
|
10536
|
+
}
|
|
10537
|
+
}
|
|
10191
10538
|
} else if (this._originalCommentsXml) {
|
|
10192
10539
|
// Passthrough — preserve original comments.xml exactly
|
|
10193
10540
|
this.zipHandler.addFile('word/comments.xml', this._originalCommentsXml);
|
|
@@ -10236,7 +10583,14 @@ export class Document {
|
|
|
10236
10583
|
}
|
|
10237
10584
|
|
|
10238
10585
|
private saveFootnotes(): void {
|
|
10239
|
-
|
|
10586
|
+
// Dirty flag gates regeneration (mirrors saveComments) so an unmodified
|
|
10587
|
+
// loaded footnotes.xml round-trips via passthrough; the count check only
|
|
10588
|
+
// covers footnotes registered directly on the manager for documents that
|
|
10589
|
+
// never had a footnotes part.
|
|
10590
|
+
if (
|
|
10591
|
+
this._footnotesModified ||
|
|
10592
|
+
(!this._originalFootnotesXml && this.footnoteManager.getCount() > 0)
|
|
10593
|
+
) {
|
|
10240
10594
|
const xml = this.footnoteManager.generateFootnotesXml();
|
|
10241
10595
|
this.zipHandler.addFile(DOCX_PATHS.FOOTNOTES, xml);
|
|
10242
10596
|
|
|
@@ -10267,7 +10621,11 @@ export class Document {
|
|
|
10267
10621
|
}
|
|
10268
10622
|
|
|
10269
10623
|
private saveEndnotes(): void {
|
|
10270
|
-
|
|
10624
|
+
// Same passthrough gating as saveFootnotes
|
|
10625
|
+
if (
|
|
10626
|
+
this._endnotesModified ||
|
|
10627
|
+
(!this._originalEndnotesXml && this.endnoteManager.getCount() > 0)
|
|
10628
|
+
) {
|
|
10271
10629
|
const xml = this.endnoteManager.generateEndnotesXml();
|
|
10272
10630
|
this.zipHandler.addFile(DOCX_PATHS.ENDNOTES, xml);
|
|
10273
10631
|
|
|
@@ -10364,10 +10722,21 @@ export class Document {
|
|
|
10364
10722
|
|
|
10365
10723
|
this.zipHandler.addFile(relsPath, xml);
|
|
10366
10724
|
|
|
10367
|
-
// Remove these relationships from the main RelationshipManager
|
|
10368
|
-
//
|
|
10725
|
+
// Remove these relationships from the main RelationshipManager so they
|
|
10726
|
+
// don't appear in document.xml.rels. Per OPC, relationship IDs are
|
|
10727
|
+
// part-scoped: a loaded header/footnote hyperlink keeps an ID from its
|
|
10728
|
+
// own .rels file that may collide with an unrelated document.xml.rels
|
|
10729
|
+
// entry (styles, settings, an image). Only remove when the main entry
|
|
10730
|
+
// is genuinely the same hyperlink (type and target match).
|
|
10369
10731
|
for (const rel of hyperlinkRels) {
|
|
10370
|
-
this.relationshipManager.
|
|
10732
|
+
const mainRel = this.relationshipManager.getRelationship(rel.id);
|
|
10733
|
+
if (
|
|
10734
|
+
mainRel &&
|
|
10735
|
+
mainRel.getType() === RelationshipType.HYPERLINK &&
|
|
10736
|
+
mainRel.getTarget() === rel.url
|
|
10737
|
+
) {
|
|
10738
|
+
this.relationshipManager.removeRelationship(rel.id);
|
|
10739
|
+
}
|
|
10371
10740
|
}
|
|
10372
10741
|
}
|
|
10373
10742
|
}
|
|
@@ -10531,20 +10900,32 @@ export class Document {
|
|
|
10531
10900
|
const defaults = new Set<string>();
|
|
10532
10901
|
const overrides = new Set<string>();
|
|
10533
10902
|
|
|
10534
|
-
//
|
|
10535
|
-
|
|
10536
|
-
|
|
10537
|
-
|
|
10538
|
-
|
|
10539
|
-
|
|
10903
|
+
// Per XML 1.0, attribute order is insignificant, values may use single or
|
|
10904
|
+
// double quotes, and empty elements may be expanded (<Override ...></Override>).
|
|
10905
|
+
// Match each opening tag and pull attributes out individually so spec-valid
|
|
10906
|
+
// packages from non-Word producers keep their entries on save (the generator
|
|
10907
|
+
// regenerates [Content_Types].xml; unmatched entries would be silently lost).
|
|
10908
|
+
const readAttr = (tag: string, name: string): string | undefined => {
|
|
10909
|
+
const match = tag.match(new RegExp(`(?:^|\\s)${name}\\s*=\\s*("([^"]*)"|'([^']*)')`));
|
|
10910
|
+
if (!match) return undefined;
|
|
10911
|
+
// Unescape so re-serialization (which escapes) doesn't double-escape
|
|
10912
|
+
return XMLBuilder.unescapeXml(match[2] ?? match[3] ?? '');
|
|
10913
|
+
};
|
|
10914
|
+
|
|
10915
|
+
for (const tagMatch of xml.matchAll(/<Default\b[^>]*>/g)) {
|
|
10916
|
+
const ext = readAttr(tagMatch[0], 'Extension');
|
|
10917
|
+
const contentType = readAttr(tagMatch[0], 'ContentType');
|
|
10918
|
+
if (ext && contentType) {
|
|
10919
|
+
defaults.add(`${ext}|${contentType}`); // Store as "ext|mimetype"
|
|
10920
|
+
}
|
|
10540
10921
|
}
|
|
10541
10922
|
|
|
10542
|
-
|
|
10543
|
-
|
|
10544
|
-
|
|
10545
|
-
|
|
10546
|
-
|
|
10547
|
-
|
|
10923
|
+
for (const tagMatch of xml.matchAll(/<Override\b[^>]*>/g)) {
|
|
10924
|
+
const partName = readAttr(tagMatch[0], 'PartName');
|
|
10925
|
+
const contentType = readAttr(tagMatch[0], 'ContentType');
|
|
10926
|
+
if (partName && contentType) {
|
|
10927
|
+
overrides.add(`${partName}|${contentType}`); // Store as "path|mimetype"
|
|
10928
|
+
}
|
|
10548
10929
|
}
|
|
10549
10930
|
|
|
10550
10931
|
return { defaults, overrides };
|
|
@@ -11509,6 +11890,12 @@ export class Document {
|
|
|
11509
11890
|
*/
|
|
11510
11891
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
11511
11892
|
private bindTrackingToElement(element: any): void {
|
|
11893
|
+
// Two-arm coverage: the getContent() arm below binds any paragraph-content
|
|
11894
|
+
// item exposing _setTrackingContext (Hyperlink, ComplexField, etc.) so their
|
|
11895
|
+
// own setText() emits tracked markup; the getRuns() arm binds the inner Runs
|
|
11896
|
+
// that actually carry the text. Both arms are required — neither subsumes the
|
|
11897
|
+
// other — to fully wire a paragraph's tracking context.
|
|
11898
|
+
|
|
11512
11899
|
// Set tracking context on element if it supports it
|
|
11513
11900
|
if (element && typeof element._setTrackingContext === 'function') {
|
|
11514
11901
|
element._setTrackingContext(this.trackingContext);
|
|
@@ -11528,14 +11915,13 @@ export class Document {
|
|
|
11528
11915
|
}
|
|
11529
11916
|
}
|
|
11530
11917
|
|
|
11531
|
-
// Bind to
|
|
11918
|
+
// Bind to content items that accept a tracking context (Hyperlink,
|
|
11919
|
+
// ComplexField, etc.). Hyperlink.setText() consults its own
|
|
11920
|
+
// trackingContext, so it must be bound here too; re-binding the inner
|
|
11921
|
+
// Runs handled above is idempotent.
|
|
11532
11922
|
if (element && typeof element.getContent === 'function') {
|
|
11533
11923
|
for (const item of element.getContent()) {
|
|
11534
|
-
if (
|
|
11535
|
-
item &&
|
|
11536
|
-
typeof item._setTrackingContext === 'function' &&
|
|
11537
|
-
typeof item.getInstruction === 'function'
|
|
11538
|
-
) {
|
|
11924
|
+
if (item && typeof item._setTrackingContext === 'function') {
|
|
11539
11925
|
item._setTrackingContext(this.trackingContext);
|
|
11540
11926
|
}
|
|
11541
11927
|
}
|
|
@@ -11876,6 +12262,10 @@ export class Document {
|
|
|
11876
12262
|
updated.size = sizeInPoints;
|
|
11877
12263
|
}
|
|
11878
12264
|
normalStyle.setRunFormatting(updated);
|
|
12265
|
+
// Re-register so the style lands in _modifiedStyleIds — otherwise
|
|
12266
|
+
// mergeStylesWithOriginal() returns the original styles.xml verbatim
|
|
12267
|
+
// for loaded documents and the change is silently dropped
|
|
12268
|
+
this.stylesManager.addStyle(normalStyle);
|
|
11879
12269
|
return this;
|
|
11880
12270
|
}
|
|
11881
12271
|
|
|
@@ -11904,6 +12294,10 @@ export class Document {
|
|
|
11904
12294
|
|
|
11905
12295
|
const existing = normalStyle.getRunFormatting() ?? {};
|
|
11906
12296
|
normalStyle.setRunFormatting({ ...existing, size: sizeInPoints });
|
|
12297
|
+
// Re-register so the style lands in _modifiedStyleIds — otherwise
|
|
12298
|
+
// mergeStylesWithOriginal() returns the original styles.xml verbatim
|
|
12299
|
+
// for loaded documents and the change is silently dropped
|
|
12300
|
+
this.stylesManager.addStyle(normalStyle);
|
|
11907
12301
|
return this;
|
|
11908
12302
|
}
|
|
11909
12303
|
|
|
@@ -12438,6 +12832,70 @@ export class Document {
|
|
|
12438
12832
|
this._endnotesModified = true;
|
|
12439
12833
|
}
|
|
12440
12834
|
|
|
12835
|
+
/**
|
|
12836
|
+
* Removes a footnote definition and every w:footnoteReference run pointing
|
|
12837
|
+
* at it in the body. Per ECMA-376 §17.11.14 each reference must resolve to
|
|
12838
|
+
* a footnote definition, so the references are stripped together with the
|
|
12839
|
+
* definition; the dirty flag ensures footnotes.xml is regenerated instead
|
|
12840
|
+
* of being restored from the original passthrough XML.
|
|
12841
|
+
* @param id - Footnote ID
|
|
12842
|
+
* @returns True if removed, false if not found or a special separator type
|
|
12843
|
+
*/
|
|
12844
|
+
removeFootnote(id: number): boolean {
|
|
12845
|
+
const removed = this.footnoteManager.removeFootnote(id);
|
|
12846
|
+
if (removed) {
|
|
12847
|
+
this._footnotesModified = true;
|
|
12848
|
+
this.removeNoteReferenceRuns('footnoteReference', id);
|
|
12849
|
+
}
|
|
12850
|
+
return removed;
|
|
12851
|
+
}
|
|
12852
|
+
|
|
12853
|
+
/**
|
|
12854
|
+
* Removes an endnote definition and every w:endnoteReference run pointing
|
|
12855
|
+
* at it in the body. Same reference-consistency contract as
|
|
12856
|
+
* {@link removeFootnote} (ECMA-376 §17.11.3).
|
|
12857
|
+
* @param id - Endnote ID
|
|
12858
|
+
* @returns True if removed, false if not found or a special separator type
|
|
12859
|
+
*/
|
|
12860
|
+
removeEndnote(id: number): boolean {
|
|
12861
|
+
const removed = this.endnoteManager.removeEndnote(id);
|
|
12862
|
+
if (removed) {
|
|
12863
|
+
this._endnotesModified = true;
|
|
12864
|
+
this.removeNoteReferenceRuns('endnoteReference', id);
|
|
12865
|
+
}
|
|
12866
|
+
return removed;
|
|
12867
|
+
}
|
|
12868
|
+
|
|
12869
|
+
/**
|
|
12870
|
+
* Strips body runs containing a footnote/endnote reference to the given
|
|
12871
|
+
* note ID so the saved document.xml never references a deleted definition.
|
|
12872
|
+
*/
|
|
12873
|
+
private removeNoteReferenceRuns(
|
|
12874
|
+
refType: 'footnoteReference' | 'endnoteReference',
|
|
12875
|
+
id: number
|
|
12876
|
+
): void {
|
|
12877
|
+
for (const para of this.getAllParagraphs()) {
|
|
12878
|
+
const content = para.getContent();
|
|
12879
|
+
// Iterate backwards so removals don't shift pending indices
|
|
12880
|
+
for (let i = content.length - 1; i >= 0; i--) {
|
|
12881
|
+
const item = content[i];
|
|
12882
|
+
if (!(item instanceof Run)) {
|
|
12883
|
+
continue;
|
|
12884
|
+
}
|
|
12885
|
+
const matches = item
|
|
12886
|
+
.getContent()
|
|
12887
|
+
.some(
|
|
12888
|
+
(c) =>
|
|
12889
|
+
c.type === refType &&
|
|
12890
|
+
(refType === 'footnoteReference' ? c.footnoteId === id : c.endnoteId === id)
|
|
12891
|
+
);
|
|
12892
|
+
if (matches) {
|
|
12893
|
+
para.removeContentAt(i);
|
|
12894
|
+
}
|
|
12895
|
+
}
|
|
12896
|
+
}
|
|
12897
|
+
}
|
|
12898
|
+
|
|
12441
12899
|
/**
|
|
12442
12900
|
* Adds a comment to a paragraph (wraps the entire paragraph)
|
|
12443
12901
|
* Creates the comment if text is provided, or uses an existing comment object
|
|
@@ -12594,13 +13052,70 @@ export class Document {
|
|
|
12594
13052
|
* @returns True if the comment was removed
|
|
12595
13053
|
*/
|
|
12596
13054
|
removeComment(id: number): boolean {
|
|
13055
|
+
// Capture reply IDs before removal — CommentManager.removeComment deletes
|
|
13056
|
+
// them too, and their body anchors must be scrubbed along with the parent's
|
|
13057
|
+
const replyIds = this.commentManager.getReplies(id).map((reply) => reply.getId());
|
|
12597
13058
|
const removed = this.commentManager.removeComment(id);
|
|
12598
13059
|
if (removed) {
|
|
13060
|
+
this.removeCommentAnchors([id, ...replyIds]);
|
|
12599
13061
|
this._commentsModified = true;
|
|
12600
13062
|
}
|
|
12601
13063
|
return removed;
|
|
12602
13064
|
}
|
|
12603
13065
|
|
|
13066
|
+
/**
|
|
13067
|
+
* Strips comment anchors (range markers and reference runs) for the given
|
|
13068
|
+
* comment IDs from every paragraph in the body, headers, footers, footnotes,
|
|
13069
|
+
* and endnotes. Without this, deleting a comment definition leaves
|
|
13070
|
+
* w:commentRangeStart/End and w:commentReference pointing at a comment that
|
|
13071
|
+
* no longer exists in comments.xml — Word reports such documents as
|
|
13072
|
+
* containing unreadable content.
|
|
13073
|
+
*/
|
|
13074
|
+
private removeCommentAnchors(commentIds: number[]): void {
|
|
13075
|
+
const strip = (paragraphs: Paragraph[]): boolean => {
|
|
13076
|
+
let removed = false;
|
|
13077
|
+
for (const paragraph of paragraphs) {
|
|
13078
|
+
for (const commentId of commentIds) {
|
|
13079
|
+
if (paragraph.removeCommentAnchor(commentId)) {
|
|
13080
|
+
removed = true;
|
|
13081
|
+
}
|
|
13082
|
+
}
|
|
13083
|
+
}
|
|
13084
|
+
return removed;
|
|
13085
|
+
};
|
|
13086
|
+
|
|
13087
|
+
strip(this.getAllParagraphs());
|
|
13088
|
+
|
|
13089
|
+
for (const entry of this.headerFooterManager.getAllHeaders()) {
|
|
13090
|
+
strip(this.extractParagraphsFromElements(entry.header.getElements()));
|
|
13091
|
+
}
|
|
13092
|
+
for (const entry of this.headerFooterManager.getAllFooters()) {
|
|
13093
|
+
strip(this.extractParagraphsFromElements(entry.footer.getElements()));
|
|
13094
|
+
}
|
|
13095
|
+
|
|
13096
|
+
// Footnote/endnote parts are raw-XML passthrough unless flagged modified,
|
|
13097
|
+
// so anchor removal must mark them dirty to take effect on save
|
|
13098
|
+
let footnotesChanged = false;
|
|
13099
|
+
for (const footnote of this.footnoteManager.getAllFootnotes()) {
|
|
13100
|
+
if (strip(footnote.getParagraphs())) {
|
|
13101
|
+
footnotesChanged = true;
|
|
13102
|
+
}
|
|
13103
|
+
}
|
|
13104
|
+
if (footnotesChanged) {
|
|
13105
|
+
this._footnotesModified = true;
|
|
13106
|
+
}
|
|
13107
|
+
|
|
13108
|
+
let endnotesChanged = false;
|
|
13109
|
+
for (const endnote of this.endnoteManager.getAllEndnotes()) {
|
|
13110
|
+
if (strip(endnote.getParagraphs())) {
|
|
13111
|
+
endnotesChanged = true;
|
|
13112
|
+
}
|
|
13113
|
+
}
|
|
13114
|
+
if (endnotesChanged) {
|
|
13115
|
+
this._endnotesModified = true;
|
|
13116
|
+
}
|
|
13117
|
+
}
|
|
13118
|
+
|
|
12604
13119
|
/**
|
|
12605
13120
|
* Checks if there are any revisions in the document
|
|
12606
13121
|
* @returns True if there are no revisions
|
|
@@ -13342,8 +13857,10 @@ export class Document {
|
|
|
13342
13857
|
// Clear all managers using their clear() methods
|
|
13343
13858
|
this.stylesManager.clear();
|
|
13344
13859
|
this.numberingManager.clear();
|
|
13345
|
-
|
|
13860
|
+
// Release buffers before clear() empties the images map; the reverse order
|
|
13861
|
+
// would leave releaseAllImageData() iterating zero entries.
|
|
13346
13862
|
this.imageManager.releaseAllImageData();
|
|
13863
|
+
this.imageManager.clear();
|
|
13347
13864
|
this.relationshipManager.clear();
|
|
13348
13865
|
this.headerFooterManager.clear();
|
|
13349
13866
|
this.bookmarkManager.clear();
|
|
@@ -13364,6 +13881,7 @@ export class Document {
|
|
|
13364
13881
|
this._originalNumberingXml = undefined;
|
|
13365
13882
|
this._originalSettingsXml = undefined;
|
|
13366
13883
|
this._originalAppPropsXml = undefined;
|
|
13884
|
+
this._originalCorePropsXml = undefined;
|
|
13367
13885
|
this._originalFootnotesXml = undefined;
|
|
13368
13886
|
this._originalEndnotesXml = undefined;
|
|
13369
13887
|
this._originalCommentsXml = undefined;
|
|
@@ -13372,6 +13890,7 @@ export class Document {
|
|
|
13372
13890
|
this._originalContentTypes = undefined;
|
|
13373
13891
|
this._settingsModified = false;
|
|
13374
13892
|
this._appPropsModified = false;
|
|
13893
|
+
this._corePropsModified = false;
|
|
13375
13894
|
this._footnotesModified = false;
|
|
13376
13895
|
this._endnotesModified = false;
|
|
13377
13896
|
this._originalWebSettingsXml = undefined;
|
|
@@ -14614,7 +15133,9 @@ export class Document {
|
|
|
14614
15133
|
const matches = originalText.match(wordPattern);
|
|
14615
15134
|
if (matches) {
|
|
14616
15135
|
replacementCount += matches.length;
|
|
14617
|
-
|
|
15136
|
+
// Replacer function inserts `replace` literally; passing the raw
|
|
15137
|
+
// string would expand $-substitution tokens ($&, $', $`, $$).
|
|
15138
|
+
newText = originalText.replace(wordPattern, () => replace);
|
|
14618
15139
|
}
|
|
14619
15140
|
} else {
|
|
14620
15141
|
// Simple substring replacement
|
|
@@ -14625,7 +15146,9 @@ export class Document {
|
|
|
14625
15146
|
const matches = originalText.match(searchPattern);
|
|
14626
15147
|
if (matches) {
|
|
14627
15148
|
replacementCount += matches.length;
|
|
14628
|
-
|
|
15149
|
+
// Replacer function inserts `replace` literally; passing the raw
|
|
15150
|
+
// string would expand $-substitution tokens ($&, $', $`, $$).
|
|
15151
|
+
newText = originalText.replace(searchPattern, () => replace);
|
|
14629
15152
|
}
|
|
14630
15153
|
}
|
|
14631
15154
|
|
|
@@ -15253,6 +15776,13 @@ export class Document {
|
|
|
15253
15776
|
* @param separator - String to insert between paragraphs (default: '\n')
|
|
15254
15777
|
* @returns Plain text content of the entire document
|
|
15255
15778
|
*
|
|
15779
|
+
* @remarks
|
|
15780
|
+
* The output depends on the `revisionHandling` mode the document was loaded with, and reflects any tracked changes applied to the document after loading.
|
|
15781
|
+
* In `'preserve'` mode the result now includes BOTH tracked-inserted (`w:ins`) and
|
|
15782
|
+
* tracked-deleted (`w:delText`) text, interleaved in document order. In
|
|
15783
|
+
* `'accept'`/`'strip'` modes (the default) the output is the post-acceptance plain
|
|
15784
|
+
* text — inserted content kept, deleted content dropped.
|
|
15785
|
+
*
|
|
15256
15786
|
* @example
|
|
15257
15787
|
* ```typescript
|
|
15258
15788
|
* const text = doc.toPlainText();
|
|
@@ -15264,23 +15794,37 @@ export class Document {
|
|
|
15264
15794
|
*/
|
|
15265
15795
|
toPlainText(separator = '\n'): string {
|
|
15266
15796
|
const paragraphs = this.getAllParagraphs();
|
|
15267
|
-
|
|
15797
|
+
// Use the revision-aware path so preserve-mode documents retain the text
|
|
15798
|
+
// carried by tracked changes (inserted w:t and deleted w:delText) instead
|
|
15799
|
+
// of silently dropping it.
|
|
15800
|
+
return paragraphs.map((p) => p.getTextIncludingRevisions()).join(separator);
|
|
15268
15801
|
}
|
|
15269
15802
|
|
|
15270
15803
|
/**
|
|
15271
15804
|
* Converts the document to Markdown format
|
|
15272
15805
|
*
|
|
15273
|
-
*
|
|
15274
|
-
*
|
|
15275
|
-
*
|
|
15276
|
-
* -
|
|
15277
|
-
* -
|
|
15278
|
-
* -
|
|
15279
|
-
*
|
|
15280
|
-
*
|
|
15281
|
-
*
|
|
15806
|
+
* Walks the body in order and renders every information-bearing construct,
|
|
15807
|
+
* falling back to inline HTML where Markdown has no native equivalent so
|
|
15808
|
+
* that no content is silently dropped:
|
|
15809
|
+
* - Headings → `#` … `######`
|
|
15810
|
+
* - Bold/italic/strikethrough → `**` / `*` / `~~`; monospace fonts → `` `code` ``
|
|
15811
|
+
* - Underline/super/subscript/highlight/color → inline HTML (`<u>`, `<sup>`,
|
|
15812
|
+
* `<sub>`, `<mark>`, `<span style="color:#…">`) when {@link MarkdownConversionOptions.htmlFallback}
|
|
15813
|
+
* - Hyperlinks → `[text](url)` (internal links resolve to `#anchor`)
|
|
15814
|
+
* - Inline images → ``
|
|
15815
|
+
* - Footnote/endnote references → `[^fn1]` / `[^en1]` with definitions appended
|
|
15816
|
+
* - Line breaks → `<br>` (or newline), tabs preserved
|
|
15817
|
+
* - Numbered/bulleted lists → `1.` / `-` with two-space indentation per nesting level
|
|
15818
|
+
* - Block quotes (Quote styles) → `>` prefixes
|
|
15819
|
+
* - Tables → GFM pipe tables; tables with merged or nested cells → inline HTML
|
|
15820
|
+
* - Field results, shapes, text boxes, structured document tags and preserved
|
|
15821
|
+
* elements → their textual content
|
|
15822
|
+
* - Tracked insertions (preserve mode) render inline; deletions wrap in `~~`/`<del>`
|
|
15823
|
+
*
|
|
15824
|
+
* Useful for document processing pipelines, content migration, documentation
|
|
15282
15825
|
* generation, and plain-text extraction with structure preserved.
|
|
15283
15826
|
*
|
|
15827
|
+
* @param options - Conversion fidelity options (see {@link MarkdownConversionOptions})
|
|
15284
15828
|
* @returns Markdown string representation of the document
|
|
15285
15829
|
*
|
|
15286
15830
|
* @example
|
|
@@ -15298,139 +15842,495 @@ export class Document {
|
|
|
15298
15842
|
* // | Alice | 30 |
|
|
15299
15843
|
* ```
|
|
15300
15844
|
*/
|
|
15301
|
-
toMarkdown(): string {
|
|
15302
|
-
const
|
|
15845
|
+
toMarkdown(options?: MarkdownConversionOptions): string {
|
|
15846
|
+
const ctx: MarkdownContext = {
|
|
15847
|
+
opts: {
|
|
15848
|
+
htmlFallback: options?.htmlFallback ?? true,
|
|
15849
|
+
footnotes: options?.footnotes ?? true,
|
|
15850
|
+
images: options?.images ?? true,
|
|
15851
|
+
},
|
|
15852
|
+
notes: [],
|
|
15853
|
+
noteMarkers: new Set<string>(),
|
|
15854
|
+
};
|
|
15303
15855
|
|
|
15856
|
+
const lines: string[] = [];
|
|
15304
15857
|
for (const element of this.bodyElements) {
|
|
15305
|
-
|
|
15306
|
-
const mdLine = this.paragraphToMarkdown(element);
|
|
15307
|
-
if (mdLine !== null) {
|
|
15308
|
-
lines.push(mdLine);
|
|
15309
|
-
lines.push('');
|
|
15310
|
-
}
|
|
15311
|
-
} else if (element instanceof Table) {
|
|
15312
|
-
lines.push(...this.tableToMarkdown(element));
|
|
15313
|
-
lines.push('');
|
|
15314
|
-
}
|
|
15315
|
-
// Other element types (SDT, AlternateContent, etc.) are skipped
|
|
15858
|
+
this.bodyElementToMarkdown(element, ctx, lines);
|
|
15316
15859
|
}
|
|
15317
15860
|
|
|
15318
|
-
// Remove trailing blank line
|
|
15861
|
+
// Remove trailing blank line(s)
|
|
15319
15862
|
while (lines.length > 0 && lines[lines.length - 1] === '') {
|
|
15320
15863
|
lines.pop();
|
|
15321
15864
|
}
|
|
15322
15865
|
|
|
15323
|
-
|
|
15866
|
+
let out = lines.join('\n');
|
|
15867
|
+
|
|
15868
|
+
// Append footnote/endnote definitions in first-reference order.
|
|
15869
|
+
if (ctx.opts.footnotes && ctx.notes.length > 0) {
|
|
15870
|
+
const defs = ctx.notes.map((n) => `[^${n.marker}]: ${n.text}`).join('\n');
|
|
15871
|
+
out = out ? `${out}\n\n${defs}` : defs;
|
|
15872
|
+
}
|
|
15873
|
+
|
|
15874
|
+
return out;
|
|
15875
|
+
}
|
|
15876
|
+
|
|
15877
|
+
/**
|
|
15878
|
+
* Renders a single body element into the running Markdown line buffer.
|
|
15879
|
+
* Recurses into block-level structured document tags.
|
|
15880
|
+
* @internal
|
|
15881
|
+
*/
|
|
15882
|
+
private bodyElementToMarkdown(element: BodyElement, ctx: MarkdownContext, lines: string[]): void {
|
|
15883
|
+
if (element instanceof Paragraph) {
|
|
15884
|
+
const md = this.paragraphToMarkdown(element, ctx);
|
|
15885
|
+
if (md !== null) {
|
|
15886
|
+
lines.push(md);
|
|
15887
|
+
lines.push('');
|
|
15888
|
+
}
|
|
15889
|
+
} else if (element instanceof Table) {
|
|
15890
|
+
const tableLines = this.tableToMarkdown(element, ctx);
|
|
15891
|
+
if (tableLines.length > 0) {
|
|
15892
|
+
lines.push(...tableLines);
|
|
15893
|
+
lines.push('');
|
|
15894
|
+
}
|
|
15895
|
+
} else if (element instanceof StructuredDocumentTag) {
|
|
15896
|
+
// Block SDT: render its inner content (paragraphs/tables/nested SDTs).
|
|
15897
|
+
for (const child of element.getContent()) {
|
|
15898
|
+
this.bodyElementToMarkdown(child, ctx, lines);
|
|
15899
|
+
}
|
|
15900
|
+
} else if (element instanceof PreservedElement) {
|
|
15901
|
+
// Best-effort: surface any text so round-trip-preserved blocks are not lost.
|
|
15902
|
+
const text = this.extractTextFromRawXml(element.getRawXml());
|
|
15903
|
+
if (text) {
|
|
15904
|
+
lines.push(this.escapeMarkdown(text));
|
|
15905
|
+
lines.push('');
|
|
15906
|
+
}
|
|
15907
|
+
}
|
|
15908
|
+
// TableOfContentsElement is intentionally skipped: its content is generated
|
|
15909
|
+
// from the headings, which are already represented in the Markdown output.
|
|
15324
15910
|
}
|
|
15325
15911
|
|
|
15326
15912
|
/**
|
|
15327
|
-
* Converts a paragraph to a Markdown
|
|
15913
|
+
* Converts a paragraph to a Markdown block (heading, list item, block quote,
|
|
15914
|
+
* or plain paragraph). Returns null for empty, non-list paragraphs.
|
|
15328
15915
|
* @internal
|
|
15329
15916
|
*/
|
|
15330
|
-
private paragraphToMarkdown(para: Paragraph): string | null {
|
|
15331
|
-
const
|
|
15332
|
-
|
|
15917
|
+
private paragraphToMarkdown(para: Paragraph, ctx: MarkdownContext): string | null {
|
|
15918
|
+
const inline = this.paragraphContentToMarkdown(para, ctx);
|
|
15919
|
+
const hasNumbering = para.hasNumbering();
|
|
15920
|
+
if (!inline && !hasNumbering) return null;
|
|
15333
15921
|
|
|
15334
|
-
// Headings
|
|
15922
|
+
// Headings take precedence over every other block style.
|
|
15335
15923
|
const headingLevel = para.detectHeadingLevel();
|
|
15336
15924
|
if (headingLevel !== null && headingLevel >= 1 && headingLevel <= 6) {
|
|
15337
|
-
return '#'.repeat(headingLevel) + ' ' +
|
|
15925
|
+
return '#'.repeat(headingLevel) + ' ' + this.toSingleLine(inline);
|
|
15338
15926
|
}
|
|
15339
15927
|
|
|
15340
|
-
|
|
15341
|
-
|
|
15342
|
-
|
|
15343
|
-
|
|
15344
|
-
|
|
15345
|
-
|
|
15928
|
+
const style = (para.getStyle() ?? '').toLowerCase();
|
|
15929
|
+
const isListStyle =
|
|
15930
|
+
style.includes('listbullet') ||
|
|
15931
|
+
style.includes('list bullet') ||
|
|
15932
|
+
style.includes('listnumber') ||
|
|
15933
|
+
style.includes('list number');
|
|
15934
|
+
|
|
15935
|
+
if (hasNumbering || isListStyle) {
|
|
15936
|
+
const level = para.getNumbering()?.level ?? 0;
|
|
15937
|
+
const indent = ' '.repeat(Math.max(0, level));
|
|
15938
|
+
const marker = this.isOrderedList(para) ? '1.' : '-';
|
|
15939
|
+
return `${indent}${marker} ${this.toSingleLine(inline)}`;
|
|
15346
15940
|
}
|
|
15347
15941
|
|
|
15348
|
-
|
|
15942
|
+
// Block quote (Quote / IntenseQuote styles).
|
|
15943
|
+
if (style.includes('quote')) {
|
|
15944
|
+
return this.toSingleLine(inline)
|
|
15945
|
+
.split('\n')
|
|
15946
|
+
.map((l) => `> ${l}`)
|
|
15947
|
+
.join('\n');
|
|
15948
|
+
}
|
|
15949
|
+
|
|
15950
|
+
return inline;
|
|
15951
|
+
}
|
|
15952
|
+
|
|
15953
|
+
/**
|
|
15954
|
+
* Determines whether a numbered paragraph uses an ordered (decimal/letter/
|
|
15955
|
+
* roman) format rather than a bullet. Resolves the numbering definition when
|
|
15956
|
+
* available, falling back to the paragraph's style name.
|
|
15957
|
+
* @internal
|
|
15958
|
+
*/
|
|
15959
|
+
private isOrderedList(para: Paragraph): boolean {
|
|
15960
|
+
const numbering = para.getNumbering();
|
|
15961
|
+
if (numbering) {
|
|
15962
|
+
const instance = this.numberingManager.getInstance(numbering.numId);
|
|
15963
|
+
if (instance) {
|
|
15964
|
+
const abstract = this.numberingManager.getAbstractNumbering(instance.getAbstractNumId());
|
|
15965
|
+
const levelDef = abstract?.getLevel(numbering.level);
|
|
15966
|
+
const format = levelDef?.getFormat();
|
|
15967
|
+
if (format) return format !== 'bullet';
|
|
15968
|
+
}
|
|
15969
|
+
}
|
|
15970
|
+
const style = (para.getStyle() ?? '').toLowerCase();
|
|
15971
|
+
if (style.includes('bullet')) return false;
|
|
15972
|
+
if (style.includes('number')) return true;
|
|
15973
|
+
return true;
|
|
15349
15974
|
}
|
|
15350
15975
|
|
|
15351
15976
|
/**
|
|
15352
15977
|
* Converts paragraph inline content to Markdown with formatting.
|
|
15353
15978
|
* @internal
|
|
15354
15979
|
*/
|
|
15355
|
-
private paragraphContentToMarkdown(para: Paragraph): string {
|
|
15356
|
-
|
|
15980
|
+
private paragraphContentToMarkdown(para: Paragraph, ctx: MarkdownContext): string {
|
|
15981
|
+
return this.inlineContentToMarkdown(para.getContent(), ctx);
|
|
15982
|
+
}
|
|
15357
15983
|
|
|
15358
|
-
|
|
15359
|
-
|
|
15360
|
-
|
|
15361
|
-
|
|
15984
|
+
/**
|
|
15985
|
+
* Converts a sequence of inline content items (runs, hyperlinks, fields,
|
|
15986
|
+
* revisions, shapes, text boxes, preserved elements) to Markdown.
|
|
15987
|
+
* @internal
|
|
15988
|
+
*/
|
|
15989
|
+
private inlineContentToMarkdown(items: ParagraphContent[], ctx: MarkdownContext): string {
|
|
15990
|
+
const parts: string[] = [];
|
|
15362
15991
|
|
|
15363
|
-
|
|
15364
|
-
|
|
15365
|
-
|
|
15366
|
-
|
|
15367
|
-
|
|
15368
|
-
|
|
15369
|
-
|
|
15370
|
-
|
|
15371
|
-
} else if (fmt.italic) {
|
|
15372
|
-
md = `*${md}*`;
|
|
15992
|
+
for (const item of items) {
|
|
15993
|
+
// ImageRun extends Run, so it must be checked first.
|
|
15994
|
+
if (item instanceof ImageRun) {
|
|
15995
|
+
if (ctx.opts.images) {
|
|
15996
|
+
const image = item.getImageElement();
|
|
15997
|
+
const alt = this.escapeMarkdown(image.getAltText() || 'image');
|
|
15998
|
+
const src = image.getRelationshipId() ?? 'image';
|
|
15999
|
+
parts.push(``);
|
|
15373
16000
|
}
|
|
15374
|
-
|
|
15375
|
-
|
|
15376
|
-
|
|
16001
|
+
} else if (item instanceof Run) {
|
|
16002
|
+
parts.push(this.runToMarkdown(item, ctx));
|
|
16003
|
+
} else if (item instanceof Hyperlink) {
|
|
16004
|
+
const runs = item.getRuns();
|
|
16005
|
+
let label =
|
|
16006
|
+
runs.length > 0
|
|
16007
|
+
? runs.map((r) => this.runToMarkdown(r, ctx, true)).join('')
|
|
16008
|
+
: this.escapeMarkdown(item.getText());
|
|
16009
|
+
if (!label) label = this.escapeMarkdown(item.getText());
|
|
16010
|
+
let url = item.getUrl() ?? '';
|
|
16011
|
+
const anchor = item.getAnchor();
|
|
16012
|
+
if (anchor) url = url ? `${url}#${anchor}` : `#${anchor}`;
|
|
16013
|
+
parts.push(`[${label}](${url})`);
|
|
16014
|
+
} else if (item instanceof Revision) {
|
|
16015
|
+
const inner = this.inlineContentToMarkdown(item.getContent(), ctx);
|
|
16016
|
+
if (!inner) continue;
|
|
16017
|
+
const type = item.getType();
|
|
16018
|
+
if (type === 'delete' || type === 'moveFrom') {
|
|
16019
|
+
// Deleted/moved-away content (only present in 'preserve' mode): mark it
|
|
16020
|
+
// so the information survives without claiming it as final body text.
|
|
16021
|
+
parts.push(ctx.opts.htmlFallback ? `<del>${inner}</del>` : `~~${inner}~~`);
|
|
16022
|
+
} else {
|
|
16023
|
+
parts.push(inner);
|
|
15377
16024
|
}
|
|
15378
|
-
|
|
15379
|
-
|
|
15380
|
-
if (
|
|
15381
|
-
|
|
15382
|
-
|
|
15383
|
-
)
|
|
15384
|
-
|
|
16025
|
+
} else if (item instanceof ComplexField) {
|
|
16026
|
+
const result = item.getResult();
|
|
16027
|
+
if (result) parts.push(this.escapeMarkdown(result));
|
|
16028
|
+
} else if (item instanceof Field) {
|
|
16029
|
+
const result = item.getCachedResult();
|
|
16030
|
+
if (result) parts.push(this.escapeMarkdown(result));
|
|
16031
|
+
} else if (item instanceof TextBox) {
|
|
16032
|
+
const text = item
|
|
16033
|
+
.getParagraphs()
|
|
16034
|
+
.map((p) => this.paragraphContentToMarkdown(p, ctx))
|
|
16035
|
+
.filter((s) => s !== '')
|
|
16036
|
+
.join(' ');
|
|
16037
|
+
if (text) parts.push(text);
|
|
16038
|
+
} else if (item instanceof Shape) {
|
|
16039
|
+
const text = item.getText();
|
|
16040
|
+
if (text) parts.push(this.escapeMarkdown(text));
|
|
16041
|
+
} else if (item instanceof PreservedElement) {
|
|
16042
|
+
const type = item.getElementType();
|
|
16043
|
+
// Comment range/reference markers carry no inline body text.
|
|
16044
|
+
if (!type.startsWith('w:comment')) {
|
|
16045
|
+
const text = this.extractTextFromRawXml(item.getRawXml());
|
|
16046
|
+
if (text) parts.push(this.escapeMarkdown(text));
|
|
15385
16047
|
}
|
|
15386
|
-
|
|
15387
|
-
parts.push(md);
|
|
15388
|
-
} else if (item instanceof Hyperlink) {
|
|
15389
|
-
const url = item.getUrl() || '';
|
|
15390
|
-
const linkText = item.getText() || url;
|
|
15391
|
-
parts.push(`[${linkText}](${url})`);
|
|
15392
16048
|
}
|
|
15393
|
-
//
|
|
16049
|
+
// RangeMarker (bookmarks) carry no display text and are skipped.
|
|
15394
16050
|
}
|
|
15395
16051
|
|
|
15396
16052
|
return parts.join('');
|
|
15397
16053
|
}
|
|
15398
16054
|
|
|
15399
16055
|
/**
|
|
15400
|
-
* Converts a
|
|
16056
|
+
* Converts a single run to Markdown, applying inline formatting and emitting
|
|
16057
|
+
* footnote/endnote markers. Tabs, breaks and symbols are preserved.
|
|
15401
16058
|
* @internal
|
|
15402
16059
|
*/
|
|
15403
|
-
private
|
|
15404
|
-
const
|
|
15405
|
-
|
|
16060
|
+
private runToMarkdown(run: Run, ctx: MarkdownContext, inLink = false): string {
|
|
16061
|
+
const fmt = run.getFormatting();
|
|
16062
|
+
const isMono = !!(
|
|
16063
|
+
fmt.font &&
|
|
16064
|
+
/^(courier|consolas|monaco|menlo|source code|fira code|jetbrains mono)/i.test(fmt.font)
|
|
16065
|
+
);
|
|
16066
|
+
const escape = (s: string): string => (isMono ? s : this.escapeMarkdown(s));
|
|
15406
16067
|
|
|
15407
|
-
const
|
|
16068
|
+
const segments: string[] = [];
|
|
16069
|
+
const markers: string[] = [];
|
|
16070
|
+
|
|
16071
|
+
for (const content of run.getContent()) {
|
|
16072
|
+
switch (content.type) {
|
|
16073
|
+
case 'text':
|
|
16074
|
+
case 'instructionText':
|
|
16075
|
+
segments.push(escape(content.value ?? ''));
|
|
16076
|
+
break;
|
|
16077
|
+
case 'tab':
|
|
16078
|
+
segments.push('\t');
|
|
16079
|
+
break;
|
|
16080
|
+
case 'carriageReturn':
|
|
16081
|
+
segments.push('\n');
|
|
16082
|
+
break;
|
|
16083
|
+
case 'break':
|
|
16084
|
+
// Page/column breaks are layout-only; line breaks become hard breaks.
|
|
16085
|
+
if (content.breakType !== 'page' && content.breakType !== 'column') {
|
|
16086
|
+
segments.push(ctx.opts.htmlFallback ? '<br>' : '\n');
|
|
16087
|
+
}
|
|
16088
|
+
break;
|
|
16089
|
+
case 'noBreakHyphen':
|
|
16090
|
+
segments.push('‑');
|
|
16091
|
+
break;
|
|
16092
|
+
case 'symbol':
|
|
16093
|
+
if (content.symbolChar) {
|
|
16094
|
+
const code = parseInt(content.symbolChar, 16);
|
|
16095
|
+
if (!Number.isNaN(code)) segments.push(escape(String.fromCharCode(code)));
|
|
16096
|
+
}
|
|
16097
|
+
break;
|
|
16098
|
+
case 'footnoteReference':
|
|
16099
|
+
if (ctx.opts.footnotes && content.footnoteId !== undefined) {
|
|
16100
|
+
markers.push(this.noteMarker(content.footnoteId, 'fn', ctx));
|
|
16101
|
+
}
|
|
16102
|
+
break;
|
|
16103
|
+
case 'endnoteReference':
|
|
16104
|
+
if (ctx.opts.footnotes && content.endnoteId !== undefined) {
|
|
16105
|
+
markers.push(this.noteMarker(content.endnoteId, 'en', ctx));
|
|
16106
|
+
}
|
|
16107
|
+
break;
|
|
16108
|
+
// Field chars, VML, soft hyphens and other layout markers contribute no text.
|
|
16109
|
+
default:
|
|
16110
|
+
break;
|
|
16111
|
+
}
|
|
16112
|
+
}
|
|
16113
|
+
|
|
16114
|
+
let text = segments.join('');
|
|
16115
|
+
if (text) {
|
|
16116
|
+
if (isMono) {
|
|
16117
|
+
text = `\`${text}\``;
|
|
16118
|
+
}
|
|
16119
|
+
// Emphasis (bold/italic innermost, strikethrough outermost).
|
|
16120
|
+
if (fmt.bold && fmt.italic) text = `***${text}***`;
|
|
16121
|
+
else if (fmt.bold) text = `**${text}**`;
|
|
16122
|
+
else if (fmt.italic) text = `*${text}*`;
|
|
16123
|
+
if (fmt.strike || fmt.dstrike) text = `~~${text}~~`;
|
|
16124
|
+
|
|
16125
|
+
if (ctx.opts.htmlFallback) {
|
|
16126
|
+
if (fmt.superscript) text = `<sup>${text}</sup>`;
|
|
16127
|
+
else if (fmt.subscript) text = `<sub>${text}</sub>`;
|
|
16128
|
+
if (fmt.highlight && fmt.highlight !== 'none') text = `<mark>${text}</mark>`;
|
|
16129
|
+
// Underline and color are the conventional appearance of a hyperlink, so
|
|
16130
|
+
// suppress them inside link labels to avoid redundant markup; elsewhere
|
|
16131
|
+
// they carry real information and are preserved as inline HTML.
|
|
16132
|
+
if (!inLink) {
|
|
16133
|
+
if (fmt.underline && fmt.underline !== 'none') text = `<u>${text}</u>`;
|
|
16134
|
+
if (fmt.color && fmt.color !== 'auto' && /^[0-9a-fA-F]{6}$/.test(fmt.color)) {
|
|
16135
|
+
text = `<span style="color:#${fmt.color}">${text}</span>`;
|
|
16136
|
+
}
|
|
16137
|
+
}
|
|
16138
|
+
}
|
|
16139
|
+
}
|
|
16140
|
+
|
|
16141
|
+
// Footnote/endnote markers follow the text, unaffected by run formatting.
|
|
16142
|
+
return text + markers.join('');
|
|
16143
|
+
}
|
|
16144
|
+
|
|
16145
|
+
/**
|
|
16146
|
+
* Resolves a footnote/endnote reference to a GFM marker and records its
|
|
16147
|
+
* definition (once) for later emission.
|
|
16148
|
+
* @internal
|
|
16149
|
+
*/
|
|
16150
|
+
private noteMarker(id: number, kind: 'fn' | 'en', ctx: MarkdownContext): string {
|
|
16151
|
+
const marker = `${kind}${id}`;
|
|
16152
|
+
if (!ctx.noteMarkers.has(marker)) {
|
|
16153
|
+
ctx.noteMarkers.add(marker);
|
|
16154
|
+
const note =
|
|
16155
|
+
kind === 'fn' ? this.footnoteManager.getFootnote(id) : this.endnoteManager.getEndnote(id);
|
|
16156
|
+
let text = '';
|
|
16157
|
+
if (note) {
|
|
16158
|
+
text = note
|
|
16159
|
+
.getParagraphs()
|
|
16160
|
+
.map((p) => this.paragraphContentToMarkdown(p, ctx))
|
|
16161
|
+
.filter((s) => s !== '')
|
|
16162
|
+
.join(' ')
|
|
16163
|
+
.replace(/\s*\n\s*/g, ' ')
|
|
16164
|
+
.trim();
|
|
16165
|
+
}
|
|
16166
|
+
ctx.notes.push({ marker, text });
|
|
16167
|
+
}
|
|
16168
|
+
return `[^${marker}]`;
|
|
16169
|
+
}
|
|
16170
|
+
|
|
16171
|
+
/**
|
|
16172
|
+
* Converts a table to Markdown. Simple grid tables become GFM pipe tables;
|
|
16173
|
+
* tables with merged or nested cells fall back to inline HTML (when enabled)
|
|
16174
|
+
* so structural information is preserved.
|
|
16175
|
+
* @internal
|
|
16176
|
+
*/
|
|
16177
|
+
private tableToMarkdown(table: Table, ctx: MarkdownContext): string[] {
|
|
16178
|
+
const rows = table.getRows();
|
|
16179
|
+
if (rows.length === 0) return [];
|
|
16180
|
+
|
|
16181
|
+
const isComplex = rows.some((row) =>
|
|
16182
|
+
row
|
|
16183
|
+
.getCells()
|
|
16184
|
+
.some(
|
|
16185
|
+
(cell) =>
|
|
16186
|
+
(cell.getColumnSpan() ?? 1) > 1 ||
|
|
16187
|
+
cell.getVerticalMerge() !== undefined ||
|
|
16188
|
+
cell.hasNestedTables()
|
|
16189
|
+
)
|
|
16190
|
+
);
|
|
16191
|
+
|
|
16192
|
+
if (isComplex && ctx.opts.htmlFallback) {
|
|
16193
|
+
return this.tableToHtmlBlock(table);
|
|
16194
|
+
}
|
|
16195
|
+
|
|
16196
|
+
const matrix = rows.map((row) => row.getCells().map((cell) => this.cellToMarkdown(cell, ctx)));
|
|
16197
|
+
const colCount = Math.max(...matrix.map((row) => row.length));
|
|
15408
16198
|
if (colCount === 0) return [];
|
|
15409
16199
|
|
|
15410
|
-
|
|
15411
|
-
const normalized = data.map((row) => {
|
|
16200
|
+
const normalized = matrix.map((row) => {
|
|
15412
16201
|
const padded = [...row];
|
|
15413
16202
|
while (padded.length < colCount) padded.push('');
|
|
15414
|
-
|
|
15415
|
-
return padded.map((cell) => cell.replace(/\|/g, '\\|').replace(/\n/g, ' ').trim());
|
|
16203
|
+
return padded;
|
|
15416
16204
|
});
|
|
15417
16205
|
|
|
15418
16206
|
const lines: string[] = [];
|
|
15419
|
-
|
|
15420
|
-
// Header row
|
|
15421
16207
|
lines.push('| ' + normalized[0]!.join(' | ') + ' |');
|
|
15422
|
-
|
|
15423
|
-
// Separator row
|
|
15424
16208
|
lines.push('| ' + normalized[0]!.map(() => '---').join(' | ') + ' |');
|
|
15425
|
-
|
|
15426
|
-
// Data rows
|
|
15427
16209
|
for (let i = 1; i < normalized.length; i++) {
|
|
15428
16210
|
lines.push('| ' + normalized[i]!.join(' | ') + ' |');
|
|
15429
16211
|
}
|
|
15430
|
-
|
|
15431
16212
|
return lines;
|
|
15432
16213
|
}
|
|
15433
16214
|
|
|
16215
|
+
/**
|
|
16216
|
+
* Renders a single table cell as inline Markdown text (formatting preserved),
|
|
16217
|
+
* with pipes escaped and line breaks collapsed for pipe-table compatibility.
|
|
16218
|
+
* @internal
|
|
16219
|
+
*/
|
|
16220
|
+
private cellToMarkdown(cell: TableCell, ctx: MarkdownContext): string {
|
|
16221
|
+
const text = cell
|
|
16222
|
+
.getParagraphs()
|
|
16223
|
+
.map((p) => this.paragraphContentToMarkdown(p, ctx))
|
|
16224
|
+
.filter((s) => s !== '')
|
|
16225
|
+
.join(' ');
|
|
16226
|
+
return text.replace(/\|/g, '\\|').replace(/<br>/g, ' ').replace(/\n/g, ' ').trim();
|
|
16227
|
+
}
|
|
16228
|
+
|
|
16229
|
+
/**
|
|
16230
|
+
* Renders a table with merged or nested cells as an inline HTML table,
|
|
16231
|
+
* honoring `colspan`/`rowspan` from grid and vertical-merge information.
|
|
16232
|
+
* @internal
|
|
16233
|
+
*/
|
|
16234
|
+
private tableToHtmlBlock(table: Table): string[] {
|
|
16235
|
+
const rows = table.getRows();
|
|
16236
|
+
|
|
16237
|
+
// Precompute each cell's starting grid column (accounting for column spans).
|
|
16238
|
+
const rowCells = rows.map((row) => {
|
|
16239
|
+
let gridStart = 0;
|
|
16240
|
+
return row.getCells().map((cell) => {
|
|
16241
|
+
const span = cell.getColumnSpan() ?? 1;
|
|
16242
|
+
const entry = { cell, gridStart, span };
|
|
16243
|
+
gridStart += span;
|
|
16244
|
+
return entry;
|
|
16245
|
+
});
|
|
16246
|
+
});
|
|
16247
|
+
|
|
16248
|
+
const html: string[] = ['<table>'];
|
|
16249
|
+
for (let ri = 0; ri < rowCells.length; ri++) {
|
|
16250
|
+
html.push('<tr>');
|
|
16251
|
+
for (const { cell, gridStart, span } of rowCells[ri]!) {
|
|
16252
|
+
// Cells continuing a vertical merge are covered by the rowspan above.
|
|
16253
|
+
if (cell.getVerticalMerge() === 'continue') continue;
|
|
16254
|
+
|
|
16255
|
+
let rowspan = 1;
|
|
16256
|
+
if (cell.getVerticalMerge() === 'restart') {
|
|
16257
|
+
for (let rj = ri + 1; rj < rowCells.length; rj++) {
|
|
16258
|
+
const below = rowCells[rj]!.find((e) => e.gridStart === gridStart);
|
|
16259
|
+
if (below?.cell.getVerticalMerge() === 'continue') rowspan++;
|
|
16260
|
+
else break;
|
|
16261
|
+
}
|
|
16262
|
+
}
|
|
16263
|
+
|
|
16264
|
+
const tag = ri === 0 ? 'th' : 'td';
|
|
16265
|
+
const attrs =
|
|
16266
|
+
(span > 1 ? ` colspan="${span}"` : '') + (rowspan > 1 ? ` rowspan="${rowspan}"` : '');
|
|
16267
|
+
|
|
16268
|
+
let content =
|
|
16269
|
+
cell
|
|
16270
|
+
.getParagraphs()
|
|
16271
|
+
.map((p) => this.paragraphContentToHTML(p))
|
|
16272
|
+
.filter((s) => s !== '')
|
|
16273
|
+
.join('<br>') || this.escapeHTML(cell.getText());
|
|
16274
|
+
|
|
16275
|
+
if (cell.hasNestedTables()) {
|
|
16276
|
+
const nestedText = cell
|
|
16277
|
+
.getRawNestedContent()
|
|
16278
|
+
.map((n) => this.extractTextFromRawXml(n.xml))
|
|
16279
|
+
.filter((s) => s)
|
|
16280
|
+
.join(' ');
|
|
16281
|
+
if (nestedText) content += `<br>${this.escapeHTML(nestedText)}`;
|
|
16282
|
+
}
|
|
16283
|
+
|
|
16284
|
+
html.push(`<${tag}${attrs}>${content}</${tag}>`);
|
|
16285
|
+
}
|
|
16286
|
+
html.push('</tr>');
|
|
16287
|
+
}
|
|
16288
|
+
html.push('</table>');
|
|
16289
|
+
return html;
|
|
16290
|
+
}
|
|
16291
|
+
|
|
16292
|
+
/**
|
|
16293
|
+
* Escapes Markdown-significant characters in literal text so they render
|
|
16294
|
+
* verbatim rather than as formatting.
|
|
16295
|
+
* @internal
|
|
16296
|
+
*/
|
|
16297
|
+
private escapeMarkdown(text: string): string {
|
|
16298
|
+
return text.replace(/([\\`*_[\]<>])/g, '\\$1');
|
|
16299
|
+
}
|
|
16300
|
+
|
|
16301
|
+
/**
|
|
16302
|
+
* Collapses hard/soft line breaks to single spaces for contexts that must
|
|
16303
|
+
* stay on one line (headings, list items, table cells).
|
|
16304
|
+
* @internal
|
|
16305
|
+
*/
|
|
16306
|
+
private toSingleLine(text: string): string {
|
|
16307
|
+
return text.replace(/<br>/g, ' ').replace(/\s*\n\s*/g, ' ');
|
|
16308
|
+
}
|
|
16309
|
+
|
|
16310
|
+
/**
|
|
16311
|
+
* Extracts visible text from a raw OOXML fragment by concatenating the
|
|
16312
|
+
* contents of its `w:t` elements and decoding XML entities. Used as a
|
|
16313
|
+
* last-resort so preserved/round-trip elements never silently lose text.
|
|
16314
|
+
* @internal
|
|
16315
|
+
*/
|
|
16316
|
+
private extractTextFromRawXml(xml: string): string {
|
|
16317
|
+
if (!xml.includes('<w:t')) return '';
|
|
16318
|
+
const parts: string[] = [];
|
|
16319
|
+
const regex = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/g;
|
|
16320
|
+
let match: RegExpExecArray | null;
|
|
16321
|
+
while ((match = regex.exec(xml)) !== null) {
|
|
16322
|
+
parts.push(match[1] ?? '');
|
|
16323
|
+
}
|
|
16324
|
+
return parts
|
|
16325
|
+
.join('')
|
|
16326
|
+
.replace(/</g, '<')
|
|
16327
|
+
.replace(/>/g, '>')
|
|
16328
|
+
.replace(/"/g, '"')
|
|
16329
|
+
.replace(/'/g, "'")
|
|
16330
|
+
.replace(/&/g, '&')
|
|
16331
|
+
.trim();
|
|
16332
|
+
}
|
|
16333
|
+
|
|
15434
16334
|
/**
|
|
15435
16335
|
* Converts the document to an HTML string
|
|
15436
16336
|
*
|
|
@@ -15567,8 +16467,13 @@ export class Document {
|
|
|
15567
16467
|
|
|
15568
16468
|
parts.push(html);
|
|
15569
16469
|
} else if (item instanceof Hyperlink) {
|
|
15570
|
-
|
|
15571
|
-
|
|
16470
|
+
// Word stores the fragment/anchor (e.g. "!/view?docid=…") separately
|
|
16471
|
+
// from the base URL; recombine them so deep links are not truncated.
|
|
16472
|
+
let rawUrl = item.getUrl() ?? '';
|
|
16473
|
+
const anchor = item.getAnchor();
|
|
16474
|
+
if (anchor) rawUrl = rawUrl ? `${rawUrl}#${anchor}` : `#${anchor}`;
|
|
16475
|
+
const url = this.escapeHTML(rawUrl);
|
|
16476
|
+
const linkText = this.escapeHTML(item.getText() || rawUrl);
|
|
15572
16477
|
parts.push(`<a href="${url}">${linkText}</a>`);
|
|
15573
16478
|
}
|
|
15574
16479
|
}
|
|
@@ -15871,7 +16776,12 @@ export class Document {
|
|
|
15871
16776
|
let index: number;
|
|
15872
16777
|
|
|
15873
16778
|
if (typeof paragraphOrIndex === 'number') {
|
|
15874
|
-
index
|
|
16779
|
+
// Numeric index is paragraph-ordinal (matching getParagraphAt /
|
|
16780
|
+
// getParagraphIndex), not a bodyElements index — tables or SDTs before
|
|
16781
|
+
// the target would otherwise shift it onto the wrong paragraph.
|
|
16782
|
+
const para = this.getParagraphAt(paragraphOrIndex);
|
|
16783
|
+
if (!para) return false;
|
|
16784
|
+
index = this.bodyElements.indexOf(para);
|
|
15875
16785
|
} else {
|
|
15876
16786
|
// Find the index of the paragraph
|
|
15877
16787
|
index = this.bodyElements.indexOf(paragraphOrIndex);
|
|
@@ -15884,13 +16794,12 @@ export class Document {
|
|
|
15884
16794
|
// No paragraphRemoved event in this branch — the paragraph is still
|
|
15885
16795
|
// structurally present, just marked deleted via revision.
|
|
15886
16796
|
if (this.trackChangesEnabled && this.trackingContext.isEnabled()) {
|
|
15887
|
-
|
|
15888
|
-
|
|
15889
|
-
|
|
15890
|
-
|
|
15891
|
-
|
|
15892
|
-
|
|
15893
|
-
}
|
|
16797
|
+
// clearContent with tracking bound REPLACES each Run/Hyperlink with
|
|
16798
|
+
// its delete revision in place. Appending a revision built from
|
|
16799
|
+
// getRuns() would leave the originals live alongside the w:del copy,
|
|
16800
|
+
// so the text would serialize twice and survive an accept-all.
|
|
16801
|
+
this.bindTrackingToElement(element);
|
|
16802
|
+
element.clearContent();
|
|
15894
16803
|
return true;
|
|
15895
16804
|
}
|
|
15896
16805
|
this.bodyElements.splice(index, 1);
|
|
@@ -16511,23 +17420,12 @@ export class Document {
|
|
|
16511
17420
|
}
|
|
16512
17421
|
};
|
|
16513
17422
|
|
|
17423
|
+
// getAllParagraphs() already walks table-cell paragraphs, so a separate
|
|
17424
|
+
// table pass would report each table hyperlink twice.
|
|
16514
17425
|
for (const paragraph of this.getAllParagraphs()) {
|
|
16515
17426
|
extractHyperlinksFromParagraph(paragraph);
|
|
16516
17427
|
}
|
|
16517
17428
|
|
|
16518
|
-
// Also check in tables
|
|
16519
|
-
for (const table of this.getTables()) {
|
|
16520
|
-
for (const row of table.getRows()) {
|
|
16521
|
-
for (const cell of row.getCells()) {
|
|
16522
|
-
// TableCell has getParagraphs method
|
|
16523
|
-
const cellParagraphs = cell instanceof TableCell ? cell.getParagraphs() : [];
|
|
16524
|
-
for (const para of cellParagraphs) {
|
|
16525
|
-
extractHyperlinksFromParagraph(para);
|
|
16526
|
-
}
|
|
16527
|
-
}
|
|
16528
|
-
}
|
|
16529
|
-
}
|
|
16530
|
-
|
|
16531
17429
|
return hyperlinks;
|
|
16532
17430
|
}
|
|
16533
17431
|
|
|
@@ -16721,6 +17619,8 @@ export class Document {
|
|
|
16721
17619
|
getBookmarks(): { bookmark: Bookmark; paragraph: Paragraph }[] {
|
|
16722
17620
|
const bookmarks: { bookmark: Bookmark; paragraph: Paragraph }[] = [];
|
|
16723
17621
|
|
|
17622
|
+
// getAllParagraphs() already walks table-cell paragraphs, so a separate
|
|
17623
|
+
// table pass would report each table bookmark twice.
|
|
16724
17624
|
for (const paragraph of this.getAllParagraphs()) {
|
|
16725
17625
|
// Get bookmarks that start in this paragraph
|
|
16726
17626
|
for (const bookmark of paragraph.getBookmarksStart()) {
|
|
@@ -16728,19 +17628,6 @@ export class Document {
|
|
|
16728
17628
|
}
|
|
16729
17629
|
}
|
|
16730
17630
|
|
|
16731
|
-
// Also check in tables
|
|
16732
|
-
for (const table of this.getTables()) {
|
|
16733
|
-
for (const row of table.getRows()) {
|
|
16734
|
-
for (const cell of row.getCells()) {
|
|
16735
|
-
for (const para of cell.getParagraphs()) {
|
|
16736
|
-
for (const bookmark of para.getBookmarksStart()) {
|
|
16737
|
-
bookmarks.push({ bookmark, paragraph: para });
|
|
16738
|
-
}
|
|
16739
|
-
}
|
|
16740
|
-
}
|
|
16741
|
-
}
|
|
16742
|
-
}
|
|
16743
|
-
|
|
16744
17631
|
return bookmarks;
|
|
16745
17632
|
}
|
|
16746
17633
|
|
|
@@ -16780,27 +17667,14 @@ export class Document {
|
|
|
16780
17667
|
getFields(): { field: FieldLike; paragraph: Paragraph; table?: Table }[] {
|
|
16781
17668
|
const results: { field: FieldLike; paragraph: Paragraph; table?: Table }[] = [];
|
|
16782
17669
|
|
|
16783
|
-
//
|
|
17670
|
+
// getAllParagraphs() already walks table-cell paragraphs, so a separate
|
|
17671
|
+
// table pass would report each table field twice.
|
|
16784
17672
|
for (const paragraph of this.getAllParagraphs()) {
|
|
16785
17673
|
for (const field of paragraph.getFields()) {
|
|
16786
17674
|
results.push({ field, paragraph });
|
|
16787
17675
|
}
|
|
16788
17676
|
}
|
|
16789
17677
|
|
|
16790
|
-
// Get fields from paragraphs inside table cells
|
|
16791
|
-
for (const table of this.getTables()) {
|
|
16792
|
-
for (const row of table.getRows()) {
|
|
16793
|
-
for (const cell of row.getCells()) {
|
|
16794
|
-
const cellParagraphs = cell instanceof TableCell ? cell.getParagraphs() : [];
|
|
16795
|
-
for (const para of cellParagraphs) {
|
|
16796
|
-
for (const field of para.getFields()) {
|
|
16797
|
-
results.push({ field, paragraph: para, table });
|
|
16798
|
-
}
|
|
16799
|
-
}
|
|
16800
|
-
}
|
|
16801
|
-
}
|
|
16802
|
-
}
|
|
16803
|
-
|
|
16804
17678
|
return results;
|
|
16805
17679
|
}
|
|
16806
17680
|
|
|
@@ -16892,7 +17766,14 @@ export class Document {
|
|
|
16892
17766
|
|
|
16893
17767
|
// 5. If format changed (e.g., bmp → png), update filename + relationships
|
|
16894
17768
|
if (result.newExtension !== extension) {
|
|
16895
|
-
|
|
17769
|
+
// Swapping the extension can collide with a distinct existing media
|
|
17770
|
+
// part (e.g. image1.bmp → image1.png when image1.png already exists;
|
|
17771
|
+
// non-Word producers don't share Word's global numbering). Resolve to
|
|
17772
|
+
// a fresh, unused name before renaming so neither image is clobbered.
|
|
17773
|
+
const newFilename = this.resolveUniqueMediaFilename(
|
|
17774
|
+
filename.replace(/\.[^.]+$/, `.${result.newExtension}`),
|
|
17775
|
+
filename
|
|
17776
|
+
);
|
|
16896
17777
|
this.imageManager.updateEntryFilename(image, newFilename);
|
|
16897
17778
|
|
|
16898
17779
|
// Update relationship targets across all relationship managers
|
|
@@ -16908,6 +17789,33 @@ export class Document {
|
|
|
16908
17789
|
return { optimizedCount, totalSavedBytes: totalSaved };
|
|
16909
17790
|
}
|
|
16910
17791
|
|
|
17792
|
+
/**
|
|
17793
|
+
* Resolves a media filename that does not collide with an existing ZIP media
|
|
17794
|
+
* part or another registered image entry. If the candidate is free, it is
|
|
17795
|
+
* returned unchanged; otherwise a numeric suffix is appended to the base name
|
|
17796
|
+
* (image1.png → image1-1.png → image1-2.png …) until a unique name is found.
|
|
17797
|
+
* @param candidate The desired filename (base name + extension, no directory)
|
|
17798
|
+
* @param currentFilename The filename being renamed away from (excluded from collision check)
|
|
17799
|
+
* @private
|
|
17800
|
+
*/
|
|
17801
|
+
private resolveUniqueMediaFilename(candidate: string, currentFilename: string): string {
|
|
17802
|
+
const dotIndex = candidate.lastIndexOf('.');
|
|
17803
|
+
const base = dotIndex === -1 ? candidate : candidate.slice(0, dotIndex);
|
|
17804
|
+
const ext = dotIndex === -1 ? '' : candidate.slice(dotIndex);
|
|
17805
|
+
|
|
17806
|
+
const isTaken = (name: string): boolean =>
|
|
17807
|
+
this.zipHandler.hasFile(`word/media/${name}`) ||
|
|
17808
|
+
this.imageManager.isFilenameOwnedByOther(name, currentFilename);
|
|
17809
|
+
|
|
17810
|
+
let name = candidate;
|
|
17811
|
+
let counter = 1;
|
|
17812
|
+
while (isTaken(name)) {
|
|
17813
|
+
name = `${base}-${counter}${ext}`;
|
|
17814
|
+
counter++;
|
|
17815
|
+
}
|
|
17816
|
+
return name;
|
|
17817
|
+
}
|
|
17818
|
+
|
|
16911
17819
|
/**
|
|
16912
17820
|
* Updates an image relationship target when the image format changes.
|
|
16913
17821
|
* Handles both document body and header/footer relationships.
|
|
@@ -17296,7 +18204,7 @@ export class Document {
|
|
|
17296
18204
|
|
|
17297
18205
|
this.bodyElements.forEach((element, index) => {
|
|
17298
18206
|
if (element instanceof Paragraph) {
|
|
17299
|
-
const isEmpty =
|
|
18207
|
+
const isEmpty = this.isParagraphRemovableBlank(element);
|
|
17300
18208
|
if (isEmpty && lastWasEmpty) {
|
|
17301
18209
|
toRemove.push(index);
|
|
17302
18210
|
}
|
|
@@ -17392,6 +18300,44 @@ export class Document {
|
|
|
17392
18300
|
return { removed, normalized };
|
|
17393
18301
|
}
|
|
17394
18302
|
|
|
18303
|
+
/**
|
|
18304
|
+
* Determines whether a body paragraph is safely removable as a duplicate
|
|
18305
|
+
* empty paragraph. Text emptiness alone is insufficient: a paragraph with no
|
|
18306
|
+
* visible text can still carry a bookmark/comment anchor, an inline image or
|
|
18307
|
+
* shape, a range marker, or a section break (sectPr) in its formatting.
|
|
18308
|
+
* Removing such a paragraph silently destroys that content. Mirrors the
|
|
18309
|
+
* richer blankness standard used by TableCell.isParaBlank.
|
|
18310
|
+
* @private
|
|
18311
|
+
*/
|
|
18312
|
+
private isParagraphRemovableBlank(para: Paragraph): boolean {
|
|
18313
|
+
if (para.getText().trim() !== '') return false;
|
|
18314
|
+
|
|
18315
|
+
// Inline section break — removing it merges sections and drops that
|
|
18316
|
+
// section's page setup / headers / margins.
|
|
18317
|
+
if (para.formatting.sectPr !== undefined) return false;
|
|
18318
|
+
|
|
18319
|
+
// Bookmark anchors — destroying them breaks REF fields / internal hyperlinks
|
|
18320
|
+
// targeting them ("Error! Bookmark not defined.").
|
|
18321
|
+
if (para.getBookmarksStart().length > 0 || para.getBookmarksEnd().length > 0) {
|
|
18322
|
+
return false;
|
|
18323
|
+
}
|
|
18324
|
+
|
|
18325
|
+
// Comment anchors.
|
|
18326
|
+
if (para.getCommentsStart().length > 0 || para.getCommentsEnd().length > 0) {
|
|
18327
|
+
return false;
|
|
18328
|
+
}
|
|
18329
|
+
|
|
18330
|
+
// Inline images, shapes, and range markers (bookmark/comment range markers)
|
|
18331
|
+
// are invisible to getText() but are real content.
|
|
18332
|
+
for (const item of para.getContent()) {
|
|
18333
|
+
if (item instanceof ImageRun || item instanceof Shape || item instanceof RangeMarker) {
|
|
18334
|
+
return false;
|
|
18335
|
+
}
|
|
18336
|
+
}
|
|
18337
|
+
|
|
18338
|
+
return true;
|
|
18339
|
+
}
|
|
18340
|
+
|
|
17395
18341
|
/**
|
|
17396
18342
|
* Sets the document language
|
|
17397
18343
|
* @param language - Language code (e.g., 'en-US', 'es-ES', 'fr-FR')
|
|
@@ -17403,6 +18349,7 @@ export class Document {
|
|
|
17403
18349
|
this.properties = {};
|
|
17404
18350
|
}
|
|
17405
18351
|
this.properties.language = language;
|
|
18352
|
+
this._corePropsModified = true;
|
|
17406
18353
|
|
|
17407
18354
|
return this;
|
|
17408
18355
|
}
|
|
@@ -17701,7 +18648,9 @@ export class Document {
|
|
|
17701
18648
|
? new RegExp(find.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')
|
|
17702
18649
|
: new RegExp(find.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
|
17703
18650
|
|
|
17704
|
-
//
|
|
18651
|
+
// getAllParagraphs() walks recursively and already includes every
|
|
18652
|
+
// table-cell paragraph, so a separate table loop would re-apply the
|
|
18653
|
+
// replacement to the same Run instances (e.g. 'cat'->'cats' => 'catss')
|
|
17705
18654
|
for (const para of this.getAllParagraphs()) {
|
|
17706
18655
|
for (const run of para.getRuns()) {
|
|
17707
18656
|
const text = run.getText();
|
|
@@ -17725,36 +18674,6 @@ export class Document {
|
|
|
17725
18674
|
}
|
|
17726
18675
|
}
|
|
17727
18676
|
|
|
17728
|
-
// Process paragraphs inside table cells
|
|
17729
|
-
for (const table of this.getTables()) {
|
|
17730
|
-
for (const row of table.getRows()) {
|
|
17731
|
-
for (const cell of row.getCells()) {
|
|
17732
|
-
for (const para of cell.getParagraphs()) {
|
|
17733
|
-
for (const run of para.getRuns()) {
|
|
17734
|
-
const text = run.getText();
|
|
17735
|
-
if (!text) continue;
|
|
17736
|
-
|
|
17737
|
-
// Check formatting constraints
|
|
17738
|
-
const runFormatting = run.getFormatting();
|
|
17739
|
-
if (matchBold && !runFormatting.bold) continue;
|
|
17740
|
-
if (matchItalic && !runFormatting.italic) continue;
|
|
17741
|
-
|
|
17742
|
-
// Perform replacement
|
|
17743
|
-
if (pattern.test(text)) {
|
|
17744
|
-
const newText = text.replace(pattern, replace);
|
|
17745
|
-
if (newText !== text) {
|
|
17746
|
-
run.setText(newText);
|
|
17747
|
-
replacedCount++;
|
|
17748
|
-
}
|
|
17749
|
-
}
|
|
17750
|
-
// Reset regex lastIndex for next iteration
|
|
17751
|
-
pattern.lastIndex = 0;
|
|
17752
|
-
}
|
|
17753
|
-
}
|
|
17754
|
-
}
|
|
17755
|
-
}
|
|
17756
|
-
}
|
|
17757
|
-
|
|
17758
18677
|
return replacedCount;
|
|
17759
18678
|
}
|
|
17760
18679
|
|