word-toolkit
v1.2.2
Published
Battle-tested toolkit for Microsoft Word add-in development — safe execution, track changes, document extraction, and more.
Maintainers
Readme
word-toolkit
npm version license npm downloads
Battle-tested toolkit for building Microsoft Word add-ins with the Office.js API.
Handles the hardest parts of Word add-in development so you can focus on your product: safe execution, track changes, document extraction, selection management, and more.
Why this exists
Building a Word add-in with Office.js is painful. The API is async, stateful, and fragile:
- Concurrent
Word.runcalls crash the add-in because there's no built-in locking - Calling
context.sync()too fast overwhelms the API and Word stops responding - Transient errors are silent and random: "service is busy", "GeneralException", timeouts with no recovery
- Track changes via the API don't exist, so you have to fake them with search/delete/insert at the word level
- OOXML with REF fields + tracked changes crashes Word Desktop with no warning
- Paragraph IDs have inconsistent casing between Word Desktop and Word Online
- Emoji, smart quotes, zero-width chars, and field codes break search operations silently
This toolkit handles all of it. Every function goes through a single execution engine with locking, retry, circuit breaking, throttling, and timeout protection. Track changes are applied using word-level diffs so they appear as native insertions and deletions. OOXML is pre-scanned for crash-inducing structures before any edit touches the document.
The result: you call getContextWithComments() to extract the full document — paragraphs, tables, headers, footers, footnotes, and endnotes — in one batch, and applyBatchChanges(changes) to apply dozens of edits, inserts, and deletes as native tracked changes, accurate, fast, and in a single Word.run. It just works on Desktop, Mac, and Web, with or without problematic document structures.
Installation
npm install word-toolkitAll dependencies are bundled automatically.
Quick Start
import {
getContextWithComments,
getSelectedParagraphIds,
shapeContextForBackend,
} from "word-toolkit";
// Extract paragraphs, tables, headers, footers, footnotes, endnotes, tracked changes, and comments
const { paragraphs, comments } = await getContextWithComments();
// Get what the user has selected
const selectedIds = getSelectedParagraphIds(paragraphs);
// Shape for your backend API
const shaped = shapeContextForBackend(paragraphs);Every call is automatically protected by the runner: locking, retry, circuit breaker, throttling, and timeout are all handled for you.
Usage Examples
Extract document content
import {
getContext,
shapeContextForBackend,
getSelectedParagraphIds,
} from "word-toolkit";
const blocks = await getContext();
const selectedIds = getSelectedParagraphIds(blocks);
const shaped = shapeContextForBackend(blocks);
await fetch("/api/analyze", {
method: "POST",
body: JSON.stringify({ context: shaped, selectedParagraphIds: selectedIds }),
});Extract content with comments
import { getContextWithComments } from "word-toolkit";
const { paragraphs, comments } = await getContextWithComments();Apply changes with track changes
import { applyBatchChanges } from "word-toolkit";
const results = await applyBatchChanges([
{ paragraphId: "abc123", newText: "Improved sentence.", operation: "edit" },
{ paragraphId: "def456", newText: "New paragraph.", operation: "insert" },
{ paragraphId: "ghi789", operation: "delete" },
]);
results.forEach((r) => {
console.log(r.success ? `Applied: ${r.paragraphId}` : `Failed: ${r.error}`);
});Edit a single paragraph
import { replaceTextByParagraphId } from "word-toolkit";
await replaceTextByParagraphId("abc123", "Updated text", "AI suggestion");Listen for selection changes
import { setupSelectionChangeListener } from "word-toolkit";
const cleanup = setupSelectionChangeListener((selectedText) => {
console.log("Selected:", selectedText);
});
// Call cleanup() when done (e.g. React useEffect return)Revert a change
import { rejectTrackedChanges } from "word-toolkit";
const result = await rejectTrackedChanges("abc123");
// { success: true, rejectedCount: 2, commentsRemoved: 1 }Check document health
import { probeWordApi, checkDocumentEditable } from "word-toolkit";
if (!(await probeWordApi())) return showError("Word is not responding");
const { editable, reason } = await checkDocumentEditable();
if (!editable) return showError(`Read-only: ${reason}`);Insert markdown as HTML at cursor
import { insertTextToCursor } from "word-toolkit";
await insertTextToCursor("**Bold** and _italic_ text");Custom text preprocessing
import { setTextPreprocessor } from "word-toolkit";
setTextPreprocessor((text) => {
return text
.replace(/\[suggestion\](.*?)\[\/suggestion\]/gi, "$1")
.replace(/\[delete\].*?\[\/delete\]/gi, "");
});How the Runner Protects You
Every function in this package goes through safeWordRun:
| Protection | What it does |
| -------------------- | ------------------------------------------------------------ |
| FIFO Lock | Only one Word.run at a time, prevents concurrent crashes |
| Retry | Transient errors retried up to 3x with exponential back-off |
| Circuit Breaker | After 15 consecutive failures, pauses 5s to let Word recover |
| Timeout | Kills hung operations after 30s |
| Generation Abort | Superseded callbacks bail out immediately |
| Throttled Sync | context.sync() spaced minimum 16ms apart |
| Busy Tracking | Detects Office stress, adds cooldown periods |
API Reference
Execution
| Function | Description |
| ----------------------------------- | ------------------------------------------- |
| safeWordRun(fn, options?) | Execute Word.run with full protection |
| throttledSync(context, options?) | Throttled context.sync() |
| waitForOfficeFree(options?) | Wait until Office stress level drops |
| ensureOfficeReady() | Wait for Office.onReady |
| probeWordApi(timeoutMs?) | Health check, returns true/false |
| checkDocumentEditable(timeoutMs?) | Write test, returns { editable, reason } |
| getWordApi() | Returns global Word object or undefined |
Track Changes
| Function | Description |
| ------------------------------------------------ | ------------------------------------------------- |
| applyBatchChanges(changes, options?, onError?) | Apply multiple edits/inserts/deletes in one batch |
| applyParagraphChange(change) | Apply a single edit, insert, or delete |
| replaceTextByParagraphId(id, text, comment?) | Edit a paragraph with tracked changes |
| insertTextAtCursorTracked(text) | Insert plain text at cursor with tracking |
| insertTextToCursor(markdown, options?) | Insert markdown as formatted HTML at cursor |
| checkIfReverted(paragraphId) | Check if changes were reverted |
| setTextPreprocessor(fn) | Override text preprocessing |
| selectTextByParagraphId(fromId, toId?) | Scroll to and select a paragraph |
Document Context
| Function | Description |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| getContext(options?) | Extract paragraphs, tables, headers, footers, footnotes, and endnotes as normalised blocks |
| getContextWithComments(options?) | Full context + comments in one batch |
| getSelectedParagraphIds(blocks) | Get highlighted paragraph IDs |
| shapeContextForBackend(blocks) | Validate and shape blocks for your API |
| ensureContextForBackend(blocks) | Validate with issue reporting |
| shapeAttachmentsForBackend(attachments) | Normalise file attachments |
| ensureContentIdMap() | Populate content ID map if empty |
| getContentIdMap() / setContentIdMap() / clearContentIdMap() | Manage content ID cache |
| setLargeDocumentWarningCallback(cb) | Register large document warning |
Selection
| Function | Description |
| ----------------------------------------- | --------------------------------------- |
| getSelectedText() | Get selected text (cleaned) |
| getDocumentText() | Get full document text |
| clearSelection() | Collapse selection to end |
| replaceHighlightedText(text, fontSize?) | Replace current selection |
| insertTextToBody(text, fontSize?) | Insert at selection or end of document |
| isHighlighting() | Selected text or empty string |
| setupSelectionChangeListener(callback) | Debounced listener (returns cleanup fn) |
| removeSelectionChangeListener() | Remove listener manually |
Comments
| Function | Description |
| ----------------------------------------- | -------------------------------- |
| getCommentsWithParaId() | All comments with paragraph IDs |
| getCommentsWithParaIdInContext(context) | Same, using existing context |
| removeCommentsFromParagraph(id) | Remove comments from a paragraph |
| rejectTrackedChanges(id) | Reject changes + remove comments |
Paragraph Lookup
| Function | Description |
| ---------------------------------------- | -------------------------------------------- |
| getParagraphById(id) | Find paragraph by ID |
| getParagraphTextById(id, options?) | Get text (supports acceptRevisions) |
| getParagraphRange(id) | Get Word.Range for paragraph or table cell |
| getParagraphByIdInContext(context, id) | Find paragraph in existing context |
| getRangeByIdInContext(context, id) | Get range in existing context |
| scrollToParagraph(fromId, toId?) | Scroll to and select paragraph(s) |
| selectContentById(id) | Select a single paragraph |
Word Service (low-level)
| Function | Description |
| ------------------------------------------------- | --------------------------------------------------- |
| ApplyMode | Enum: WITH_TRACKED_CHANGES, WITH_COMMENT, RAW |
| run(callback) / runResult(callback) | Safe Word.run wrappers |
| withTracking(callback) | Run with track changes ON (restores original) |
| withoutTracking(callback) | Run with track changes OFF (restores original) |
| applyModifications(mode, callback) | Dispatch by ApplyMode |
| replaceSingleParagraph(id, text, comment, mode) | Word-level diff replace |
| deleteSingleParagraph(id, mode, comment) | Delete with tracking |
| insertComment(ctx, comment, range) | Add comment to a range |
| getRangeBetween(from, to) | Range spanning two endpoints |
OOXML
| Function | Description |
| ------------------------------------------------ | -------------------------------------------- |
| invalidateOoxmlCache() | Clear cached analysis |
| checkForProblematicTrackedChanges(ctx) | Detect crash-inducing REF fields |
| checkOoxmlForMoveOperations(ctx) | Detect move operations |
| parseParagraphOoxml(pXml, index) | Parse paragraph XML to { id, text, diffs } |
| extractParaId(pXml) / extractRunText(runXml) | XML element helpers |
Text Utilities
| Function | Description |
| ------------------------------ | -------------------------------------------------- |
| cleanText(text) | Strip control chars, field codes, zero-width chars |
| cleanUnicode(text) | Strip chars that break Word search |
| normalizeQuotes(text) | Smart quotes to ASCII |
| textsMatch(a, b, strict?) | Fuzzy comparison (90% threshold) |
| hashText(text) | Fast DJB2 hash |
| normalizeForComparison(text) | Full normalisation for comparison |
| isEmptyText(text) | Check if empty after cleaning |
| toChunks(input, charCount) | Fixed-size substring generator |
Diff Utilities
| Function | Description |
| --------------------------------- | -------------------------------------- |
| DiffType | Constants: ADDED, REMOVED, EQUAL |
| getDiff(oldText, newText) | Diff optimised for Word track changes |
| diffWords(oldText, newText) | Raw word-level diff |
| getCurrentTextFromDiffs(diffs) | Reconstruct new text |
| getOriginalTextFromDiffs(diffs) | Reconstruct original text |
Emoji
| Function | Description |
| ------------------------------------ | ----------------------------------- |
| renderEmojiToDataUri(grapheme) | Emoji to PNG data-URI |
| isEmojiGrapheme(grapheme) | Detect emoji grapheme |
| replaceEmojiWithInlineImages(html) | Replace emoji with <img> for Word |
Dependencies
All dependencies install automatically.
| Package | Purpose |
| ----------- | --------------------------- |
| diff | Word-level text diffing |
| uuid | Unique ID generation |
| marked | Markdown to HTML conversion |
| dompurify | HTML sanitisation |
License
MIT
