diff --git a/apps/docs/core/superdoc/configuration.mdx b/apps/docs/core/superdoc/configuration.mdx index 0150bd0b23..63f5ba1c83 100644 --- a/apps/docs/core/superdoc/configuration.mdx +++ b/apps/docs/core/superdoc/configuration.mdx @@ -215,6 +215,9 @@ new SuperDoc({ Whether the layout engine treats tracked changes as active. + + When `true` (default), a tracked replacement (insertion paired with deletion) is resolved as a single change with one accept/reject action — closer to the Google Docs model. When `false`, each insertion and each deletion is an independent change with its own id, matching Microsoft Word and ECMA-376 §17.13.5. + @@ -229,6 +232,18 @@ new SuperDoc({ }); ``` +Opt into Microsoft Word / ECMA-376-style independent revisions, where each insertion and each deletion has its own id and resolves on its own: + +```javascript +new SuperDoc({ + selector: '#editor', + document: 'contract.docx', + modules: { + trackChanges: { pairReplacements: false }, + }, +}); +``` + ### Toolbar module diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index fb653c0be1..8d876602c0 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -2093,6 +2093,7 @@ export class Editor extends EventEmitter { mockWindow: this.options.mockWindow ?? null, mockDocument: this.options.mockDocument ?? null, isNewFile: this.options.isNewFile ?? false, + trackedChangesOptions: this.options.trackedChanges ?? null, }); } } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js index 2170d65dd9..d537cf71ea 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js @@ -208,6 +208,14 @@ class SuperConverter { this.fonts = params?.fonts || {}; + /** + * Track-changes options forwarded from the editor. Consumed during + * import (e.g. by `buildTrackedChangeIdMap`) so behaviors like + * `pairReplacements` can be toggled per SuperDoc instance. + * @type {{ pairReplacements?: boolean } | null} + */ + this.trackedChangesOptions = params?.trackedChangesOptions || null; + this.addedMedia = {}; this.comments = []; this.footnotes = []; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js index a4ed2677f8..c743bb8653 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js @@ -152,7 +152,9 @@ export const createDocumentJson = (docx, converter, editor) => { patchNumberingDefinitions(docx); const numbering = getNumberingDefinitions(docx); - converter.trackedChangeIdMap = buildTrackedChangeIdMap(docx); + converter.trackedChangeIdMap = buildTrackedChangeIdMap(docx, { + pairReplacements: converter.trackedChangesOptions?.pairReplacements !== false, + }); const comments = importCommentData({ docx, nodeListHandler, converter, editor }); const footnotes = importFootnoteData({ docx, nodeListHandler, converter, editor, numbering }); const endnotes = importEndnoteData({ docx, nodeListHandler, converter, editor, numbering }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js index ab7187b469..f190122f35 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js @@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; /** * @typedef {{ type: string, author: string, date: string, internalId: string }} TrackedChangeEntry - * @typedef {{ lastTrackedChange: TrackedChangeEntry | null }} WalkContext + * @typedef {{ lastTrackedChange: TrackedChangeEntry | null, pairReplacements: boolean }} WalkContext */ const TRACKED_CHANGE_NAMES = new Set(['w:ins', 'w:del']); @@ -70,7 +70,7 @@ function assignInternalId(element, idMap, context, insideTrackedChange) { date: element.attributes?.['w:date'] ?? '', }; - if (context.lastTrackedChange && isReplacementPair(context.lastTrackedChange, current)) { + if (context.pairReplacements && context.lastTrackedChange && isReplacementPair(context.lastTrackedChange, current)) { // Second half of a replacement — share the first half's UUID, but only // if this w:id hasn't already been mapped. A reused id that was already // part of an earlier pair must keep its original mapping. @@ -128,23 +128,27 @@ function walkElements(elements, idMap, context, insideTrackedChange = false) { * Builds a map from OOXML `w:id` values to stable internal UUIDs by scanning * `word/document.xml`. * - * Word tracked replacements use separate `w:id` values for the delete and - * insert halves. This function detects adjacent opposite-type changes with - * matching author and date and maps both halves to the same internal UUID so - * the editor can resolve them as a single logical change. + * When `pairReplacements` is `true` (the default), Word tracked replacements + * are detected as adjacent opposite-type changes with matching author and + * date, and both halves map to the same internal UUID so the editor can + * resolve them as one logical change. When `pairReplacements` is `false`, + * each `w:id` maps to its own UUID — matching the ECMA-376 §17.13.5 model + * where every `` and `` is an independent revision. * * Must run before comment import so all consumers — translators, comment * helpers, and the tracked-change resolver — see a fully populated map. * * @param {object} docx Parsed DOCX package + * @param {{ pairReplacements?: boolean }} [options] * @returns {Map} Word `w:id` → internal UUID */ -export function buildTrackedChangeIdMap(docx) { +export function buildTrackedChangeIdMap(docx, options = {}) { const body = docx?.['word/document.xml']?.elements?.[0]; if (!body?.elements) return new Map(); + const pairReplacements = options.pairReplacements !== false; const idMap = new Map(); - walkElements(body.elements, idMap, { lastTrackedChange: null }); + walkElements(body.elements, idMap, { lastTrackedChange: null, pairReplacements }); return idMap; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js index 8bc6ce61d9..060ccd4591 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js @@ -255,4 +255,39 @@ describe('buildTrackedChangeIdMap', () => { expect(idMap.get('0')).toBe(idMap.get('1')); }); + + describe('pairReplacements: false (unpaired mode)', () => { + it('keeps adjacent w:del + w:ins with matching author/date as independent ids', () => { + const docx = createDocx( + paragraph( + trackedChange('w:del', '10', 'Alice', '2024-01-01T00:00:00Z'), + trackedChange('w:ins', '11', 'Alice', '2024-01-01T00:00:00Z'), + ), + ); + + const idMap = buildTrackedChangeIdMap(docx, { pairReplacements: false }); + + expect(idMap.size).toBe(2); + expect(idMap.get('10')).toBeTruthy(); + expect(idMap.get('11')).toBeTruthy(); + expect(idMap.get('10')).not.toBe(idMap.get('11')); + }); + + it('still maps each standalone tracked change to its own UUID', () => { + const docx = createDocx(paragraph(trackedChange('w:del', '1')), paragraph(trackedChange('w:ins', '2'))); + + const idMap = buildTrackedChangeIdMap(docx, { pairReplacements: false }); + + expect(idMap.size).toBe(2); + expect(idMap.get('1')).not.toBe(idMap.get('2')); + }); + + it('treats real Word replacement siblings as independent', () => { + const docx = createDocx(paragraph(wordDelete('0', 'test '), wordInsert('1', 'abc '))); + + const idMap = buildTrackedChangeIdMap(docx, { pairReplacements: false }); + + expect(idMap.get('0')).not.toBe(idMap.get('1')); + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts index 82c6be5ba1..b146287783 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts @@ -352,6 +352,19 @@ export interface EditorOptions { /** Comment highlight configuration */ comments?: CommentConfig; + /** + * Track-changes runtime configuration forwarded from the SuperDoc-level + * `modules.trackChanges` config. Read by the TrackChanges extension and + * by the SuperConverter during import. Fields are all optional; missing + * ones fall back to defaults resolved at SuperDoc construction time. + */ + trackedChanges?: { + visible?: boolean; + mode?: 'review' | 'original' | 'final' | 'off'; + enabled?: boolean; + pairReplacements?: boolean; + }; + /** Whether this is a new file */ isNewFile?: boolean; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js index 96498b49f9..a91788c952 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js @@ -1896,6 +1896,89 @@ describe('TrackChanges extension commands', () => { expect(meta.insertedMark.attrs.id).toBe(meta.deletionMark.attrs.id); }); + it('gives each replacement mark its own ID when pairReplacements is false', () => { + const doc = createDoc('Hello world'); + const state = createState(doc); + + let dispatchedTr; + const dispatch = vi.fn((tr) => { + dispatchedTr = tr; + state.apply(tr); + }); + + commands.insertTrackedChange({ + from: 7, + to: 12, + text: 'universe', + user: { name: 'Test', email: 'test@example.com' }, + })({ + state, + dispatch, + editor: { + options: { + user: { name: 'Default', email: 'default@example.com' }, + trackedChanges: { pairReplacements: false }, + }, + commands: { addCommentReply: vi.fn() }, + }, + }); + + const meta = dispatchedTr.getMeta(TrackChangesBasePluginKey); + expect(meta.insertedMark).toBeDefined(); + expect(meta.deletionMark).toBeDefined(); + expect(meta.insertedMark.attrs.id).not.toBe(meta.deletionMark.attrs.id); + }); + + it('resolves only the targeted half of a replacement in unpaired mode', () => { + const { editor: interactionEditor } = initTestEditor({ + mode: 'text', + content: '

Hello world

', + user: { name: 'Track Tester', email: 'track@example.com' }, + trackedChanges: { pairReplacements: false }, + }); + + try { + const worldRange = getSubstringRange(interactionEditor.state.doc, 'world'); + interactionEditor.commands.insertTrackedChange({ + from: worldRange.from, + to: worldRange.to, + text: 'universe', + }); + + // Gather both independent ids for the insertion and deletion halves. + const changes = []; + interactionEditor.state.doc.descendants((node) => { + node.marks.forEach((mark) => { + if (mark.type.name === TrackInsertMarkName || mark.type.name === TrackDeleteMarkName) { + changes.push({ type: mark.type.name, id: mark.attrs.id }); + } + }); + }); + const insertion = changes.find((c) => c.type === TrackInsertMarkName); + const deletion = changes.find((c) => c.type === TrackDeleteMarkName); + expect(insertion).toBeDefined(); + expect(deletion).toBeDefined(); + expect(insertion.id).not.toBe(deletion.id); + + // Accepting the insertion must not touch the deletion side. + interactionEditor.commands.acceptTrackedChangeById(insertion.id); + expect(getMarkedText(interactionEditor.state.doc, TrackInsertMarkName)).toBe(''); + expect(getMarkedText(interactionEditor.state.doc, TrackDeleteMarkName)).toBe('world'); + + // The deletion is still independently resolvable by its own id. + // Rejecting the deletion keeps the original text (unmarking it); + // the previously accepted insertion stays. Both words coexist in + // the final doc, which is the point of treating them as + // independent revisions. + interactionEditor.commands.rejectTrackedChangeById(deletion.id); + expect(getMarkedText(interactionEditor.state.doc, TrackDeleteMarkName)).toBe(''); + expect(interactionEditor.state.doc.textContent).toContain('universe'); + expect(interactionEditor.state.doc.textContent).toContain('world'); + } finally { + interactionEditor.destroy(); + } + }); + it('attaches comment to replacement using shared ID', () => { const doc = createDoc('Hello world'); const state = createState(doc); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js index 11d0b9636f..4d6c6b2603 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js @@ -12,6 +12,8 @@ import { CommentsPluginKey, createOrUpdateTrackedChangeComment } from '../commen import { findMarkInRangeBySnapshot } from './trackChangesHelpers/markSnapshotHelpers.js'; import { hasExpandedSelection } from '@utils/selectionUtils.js'; +const readPairReplacements = (editor) => editor?.options?.trackedChanges?.pairReplacements !== false; + export const TrackChanges = Extension.create({ name: 'trackChanges', @@ -313,12 +315,26 @@ export const TrackChanges = Extension.create({ // Get marks from original position BEFORE any changes for format preservation const marks = state.doc.resolve(from).marks(); - // For replacements (both deletion and insertion), generate a shared ID upfront - // so the deletion and insertion marks are linked together + // id-minting strategy for a tracked insert/delete/replace: + // - One `primaryId` anchors the operation. When the caller supplies + // `id` (e.g. the Document API write adapter), that becomes the + // primary; otherwise we mint a fresh UUID. + // - The primary id is used for the insertion (pure insert) or the + // lone deletion (pure delete), and always as the `changeId` we + // report back — comment threads key off this id too. + // - For a replacement: in paired mode both halves share the + // primary id (Google-Docs-like one-click resolve). In unpaired + // mode (modules.trackChanges.pairReplacements: false), the + // insertion keeps the primary id and the deletion mints its own + // fresh id via markDeletion, so each revision is independently + // addressable per ECMA-376 §17.13.5. + const pairReplacements = readPairReplacements(editor); const isReplacement = from !== to && text; - const sharedId = id ?? (isReplacement ? uuidv4() : null); + const primaryId = id ?? uuidv4(); + const insertionId = primaryId; + const deletionId = pairReplacements || !isReplacement ? primaryId : null; - let changeId = sharedId; + const changeId = primaryId; let insertPos = to; // Default insert position is after the selection let deletionMark = null; let deletionNodes = []; @@ -331,13 +347,10 @@ export const TrackChanges = Extension.create({ to, user: resolvedUser, date, - id: sharedId, + id: deletionId, }); deletionMark = result.deletionMark; deletionNodes = result.nodes || []; - if (!changeId) { - changeId = deletionMark.attrs.id; - } // Map the insert position through the deletion mapping insertPos = result.deletionMap.map(to); } @@ -358,12 +371,8 @@ export const TrackChanges = Extension.create({ to: insertedTo, user: resolvedUser, date, - id: sharedId, + id: insertionId, }); - - if (!changeId) { - changeId = insertedMark.attrs.id; - } } // Store metadata for external consumers (pass full mark objects for comments plugin) @@ -669,6 +678,14 @@ const getChangesByIdToResolve = (state, id) => { const matchingChange = trackedChanges[changeIndex]; const matchingId = matchingChange.mark.attrs.id; + // The neighbor walk collects every adjacent segment that shares the same id. + // This catches: + // - A single logical mark split across multiple segments (e.g. because + // surrounding text marks differ) — always correct to resolve together. + // - The paired opposite-type mark when pairReplacements is true (shared id). + // In unpaired mode, the ins/del halves have distinct ids so the walk stops + // at the revision boundary naturally — no special casing needed here. + const linkedBefore = []; const linkedAfter = []; diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index a8e7593f69..6e75e4aed0 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -689,6 +689,7 @@ const editorOptions = (doc) => { highlightColors: commentsModuleConfig.value?.highlightColors, highlightOpacity: commentsModuleConfig.value?.highlightOpacity, }, + trackedChanges: proxy.$superdoc.config.modules?.trackChanges, editorCtor: useLayoutEngine ? PresentationEditor : undefined, onBeforeCreate: onEditorBeforeCreate, onCreate: onEditorCreate, diff --git a/packages/superdoc/src/core/helpers/normalize-track-changes-config.js b/packages/superdoc/src/core/helpers/normalize-track-changes-config.js index 609ee8457b..1dc9bf60da 100644 --- a/packages/superdoc/src/core/helpers/normalize-track-changes-config.js +++ b/packages/superdoc/src/core/helpers/normalize-track-changes-config.js @@ -2,7 +2,7 @@ /** * @typedef {'review' | 'original' | 'final' | 'off'} TrackChangesMode - * @typedef {{ visible: boolean, mode: TrackChangesMode, enabled: boolean }} NormalizedTrackChangesConfig + * @typedef {{ visible: boolean, mode: TrackChangesMode, enabled: boolean, pairReplacements: boolean }} NormalizedTrackChangesConfig */ const ALLOWED_MODES = /** @type {const} */ (['review', 'original', 'final', 'off']); @@ -77,6 +77,10 @@ export function normalizeTrackChangesConfig(config) { const enabled = resolveBool(fromCanonical?.enabled, fromLegacyLayout?.enabled, true); + // Replacement pairing is only surfaced on the canonical path. The legacy + // buckets never exposed this knob, so there's no alias to resolve. + const pairReplacements = resolveBool(fromCanonical?.pairReplacements, undefined, true); + // Default mode derives from documentMode + visibility so a viewing-mode // document without an explicit mode falls back to 'original' unless the // consumer asked for tracked changes to be visible. @@ -85,7 +89,7 @@ export function normalizeTrackChangesConfig(config) { const mode = resolveMode(fromCanonical?.mode, fromLegacyLayout?.mode, defaultMode); /** @type {NormalizedTrackChangesConfig} */ - const normalized = { visible, mode, enabled }; + const normalized = { visible, mode, enabled, pairReplacements }; // Write-through to every path so all existing internal reads see the same // resolved values without needing to migrate each call site in this pass. diff --git a/packages/superdoc/src/core/helpers/normalize-track-changes-config.test.js b/packages/superdoc/src/core/helpers/normalize-track-changes-config.test.js index 9d3b543bfc..8e496b32b2 100644 --- a/packages/superdoc/src/core/helpers/normalize-track-changes-config.test.js +++ b/packages/superdoc/src/core/helpers/normalize-track-changes-config.test.js @@ -18,7 +18,7 @@ describe('normalizeTrackChangesConfig', () => { const config = {}; const result = normalizeTrackChangesConfig(config); - expect(result).toEqual({ visible: false, mode: 'review', enabled: true }); + expect(result).toEqual({ visible: false, mode: 'review', enabled: true, pairReplacements: true }); expect(config.modules.trackChanges).toEqual(result); expect(config.trackChanges).toEqual({ visible: false }); expect(config.layoutEngineOptions.trackedChanges).toEqual({ mode: 'review', enabled: true }); @@ -54,7 +54,7 @@ describe('normalizeTrackChangesConfig', () => { }; const result = normalizeTrackChangesConfig(config); - expect(result).toEqual({ visible: true, mode: 'original', enabled: false }); + expect(result).toEqual({ visible: true, mode: 'original', enabled: false, pairReplacements: true }); expect(warnSpy).not.toHaveBeenCalled(); }); @@ -160,7 +160,7 @@ describe('normalizeTrackChangesConfig', () => { }; const result = normalizeTrackChangesConfig(config); - expect(result).toEqual({ visible: true, mode: 'original', enabled: false }); + expect(result).toEqual({ visible: true, mode: 'original', enabled: false, pairReplacements: true }); }); }); @@ -172,7 +172,7 @@ describe('normalizeTrackChangesConfig', () => { }; const result = normalizeTrackChangesConfig(config); - expect(result).toEqual({ visible: false, mode: 'review', enabled: true }); + expect(result).toEqual({ visible: false, mode: 'review', enabled: true, pairReplacements: true }); expect(warnSpy).not.toHaveBeenCalled(); }); @@ -184,7 +184,7 @@ describe('normalizeTrackChangesConfig', () => { }; const result = normalizeTrackChangesConfig(config); - expect(result).toEqual({ visible: false, mode: 'review', enabled: true }); + expect(result).toEqual({ visible: false, mode: 'review', enabled: true, pairReplacements: true }); expect(Array.isArray(config.modules)).toBe(false); }); @@ -218,6 +218,44 @@ describe('normalizeTrackChangesConfig', () => { }); }); + describe('pairReplacements flag', () => { + it('defaults to true when not supplied', () => { + const result = normalizeTrackChangesConfig({}); + expect(result.pairReplacements).toBe(true); + }); + + it('accepts pairReplacements: false on the canonical path', () => { + const result = normalizeTrackChangesConfig({ + modules: { trackChanges: { pairReplacements: false } }, + }); + expect(result.pairReplacements).toBe(false); + }); + + it('mirrors pairReplacements onto the canonical path write-through', () => { + const config = { + modules: { trackChanges: { pairReplacements: false } }, + }; + normalizeTrackChangesConfig(config); + expect(config.modules.trackChanges.pairReplacements).toBe(false); + }); + + it('coerces non-boolean pairReplacements to the default (true)', () => { + const result = normalizeTrackChangesConfig({ + modules: { trackChanges: { pairReplacements: 'no' } }, + }); + expect(result.pairReplacements).toBe(true); + }); + + it('is not derivable from any legacy key (no alias)', () => { + // Even if a legacy key is set, pairReplacements stays at its default. + const result = normalizeTrackChangesConfig({ + trackChanges: { visible: true }, + layoutEngineOptions: { trackedChanges: { mode: 'original' } }, + }); + expect(result.pairReplacements).toBe(true); + }); + }); + describe('extended mode values (final / off)', () => { it('preserves mode: "final" supplied via the legacy layout path', () => { const config = { @@ -256,7 +294,7 @@ describe('normalizeTrackChangesConfig', () => { }; const result = normalizeTrackChangesConfig(config); - expect(result).toEqual({ visible: true, mode: 'original', enabled: false }); + expect(result).toEqual({ visible: true, mode: 'original', enabled: false, pairReplacements: true }); expect(warnSpy).toHaveBeenCalledTimes(2); const messages = warnSpy.mock.calls.map((call) => call[0]); expect(messages.some((m) => /config\.trackChanges\b/.test(m) && !/layoutEngineOptions/.test(m))).toBe(true); @@ -308,7 +346,7 @@ describe('normalizeTrackChangesConfig', () => { const first = normalizeTrackChangesConfig(config); const second = normalizeTrackChangesConfig(config); - expect(first).toEqual({ visible: true, mode: 'final', enabled: true }); + expect(first).toEqual({ visible: true, mode: 'final', enabled: true, pairReplacements: true }); expect(second).toEqual(first); }); }); diff --git a/packages/superdoc/src/core/types/index.js b/packages/superdoc/src/core/types/index.js index 6c31b4e0db..5e6a6ef810 100644 --- a/packages/superdoc/src/core/types/index.js +++ b/packages/superdoc/src/core/types/index.js @@ -544,6 +544,7 @@ * - 'final': show the document with changes applied * - 'off': disable tracked-change rendering * @property {boolean} [enabled=true] Whether the layout engine treats tracked changes as active + * @property {boolean} [pairReplacements=true] When `true` (default), a tracked replacement (insertion paired with deletion) is resolved as a single change with one accept/reject action — closer to the Google Docs model. When `false`, each insertion and each deletion is an independent change with its own id, matching Microsoft Word and ECMA-376 §17.13.5. */ /**