Skip to Content
SimulatorsWord changelog

Simulators

Word changelog

What changed in each Word simulator release, newest first.

v0.0.2 — 2026-11-05

  1. A range’s tracked changes, listed as Word lists them. getTrackedChanges() on a range answers only the changes in the paragraph the range ends in, clipped to the range, as Word for the web does. A range that ends with a paragraph mark, such as body.getRange('Whole'), answers none. The body’s and each paragraph’s own getTrackedChanges() still answer every change.

  2. Search options take effect, and a refusal answers Word’s code. search on a body, paragraph or range honors matchCase, matchWholeWord, matchPrefix, matchSuffix, ignorePunct and ignoreSpace, which office.js sends packed into one number that the twin used to read as no options at all. A refusal the document makes, such as a location a member does not take, an empty search string or a getReviewedText version Word does not know, answers Word’s own code (InvalidArgument, InvalidRequest, NotImplemented) instead of GeneralException.

  3. Walk paragraphs with getNext and getPrevious. Paragraph.getNext(), getPrevious() and their OrNullObject forms answer the neighbouring paragraph, so an add-in that walks a document paragraph by paragraph runs on the twin. Past the last or before the first paragraph they throw ItemNotFound as Word does, or answer the null object, where the twin answered ApiNotFound before.

  4. Comments answer as Word for the web on a Microsoft 365 work account does. A thread’s replies list its first comment as item 0, then each reply, even when there is no reply yet. Deleting that item 0 deletes the whole thread. A comment or reply the twin writes answers user@example.com, its signed-in user’s address, as authorEmail, where it answered null. A resolved thread refuses a reply with Word’s NotAllowed instead of taking it.

  5. A tracked change already accepted or rejected stays addressable. Accepting or rejecting it again answers no error, as in Word for the web, and its type, author and text still load. Before, the twin answered ItemNotFound or GeneralException. As in Word, a settled change keeps the place it was listed at: rejecting an insertion or accepting a deletion again takes out whatever text now sits there, and its text reads that text.

  6. Use an item after loading its collection again. Loading a collection’s items a second time, as an add-in does to refresh a list after an edit, and then calling a method on one of them answers that item as Word does, where the twin answered ApiNotFound before.

  7. Refusals answer as Word for the web does, and enum arguments take any case. getRange('After') of the body, or of the document’s last paragraph, answers Office.js’s null object, as Word does, so text inserted there goes nowhere; before, the twin refused it or answered an empty range. A value outside its enum, such as a changeTrackingMode of 'Bogus', answers InvalidRequest with no debugInfo.errorLocation. Paragraph.insertOoxml at Before or After answers InvalidArgument, and insertOoxml of anything but a flat pkg:package answers GeneralException and leaves the document as it was, where the twin used to add an empty paragraph. An enum argument spelt in another case, such as getRange('whole') or changeTrackingMode = 'trackAll', works as its own spelling does, and a tracked change’s getRange answers the whole change at every location, as Word’s does.

  8. Take a sub-range of a range, and delete a reply. Range.getRange() answers the range’s Whole, Start, End, After or Content as Word does, and refuses Before with InvalidArgument, so an add-in can narrow a search hit before it edits or comments on it. CommentReply.delete() deletes one reply and leaves its thread; deleting a thread’s item 0 deletes the whole thread, as Word for the web does on a work account. Both answered ApiNotFound before.

  9. getOoxml packages hold Word’s default parts. Every package now carries the styles, theme, font-table and web-settings parts Word writes, so a style the document names, such as CommentReference, is defined in the package, and its settings hold Word’s defaults. people.xml comes only with comments, as in Word.

  10. Wildcard search. search with matchWildcards matches Word’s wildcard grammar (?, *, [] and [!], {n,m} and @, < and >, groups in ()) as Word for the web does, instead of answering NotImplemented. A malformed expression answers InvalidArgument, and every search refusal names Body._search2 (or the paragraph’s or range’s) as its location, as Word’s does.

  11. A comment the twin writes names its author in people.xml. A comment or reply the signed-in Test User writes now adds a person for that user to the package’s people.xml, with presence provider AD and user id 0000000000000000, as Word for the web does for its signed-in account. A document seeded without people gains the part. A package names only the persons of authors whose comments or revisions it holds, so a range’s getOoxml no longer carries every seeded person.

  12. A reply anchors after its thread’s last reply. A reply the twin adds to a thread that already has replies now goes after them in the package, as in Word, where it went before them.

  13. Count each test’s syncs. office.stats() resolves to the context.sync() round trips, and the actions in them, since the last office.mark() or reset, so a test can hold an operation to a sync budget. The batches office.js sends of its own accord, the release after a Word.run and the ready notice once Office.onReady has run, aren’t counted, so a test’s count never depends on when they land. The twin serves the same counts at GET /_rystic/inspect?view=officejs.

  14. Run your suite across your documents. npx officejs-harness corpus ./fixtures -- npx vitest run runs your suite once per .docx in a folder, each document on its own freshly seeded twin and all at once, and prints a documents × tests matrix of passes, failures and syncs; each failure names the batch that failed and the command that reruns it on that document. office.seedDocx(path) seeds the twin from a .docx, and connect({ resetTo }) makes every office.reset() start from one.

  15. Text inside a content control around a table row or cell. A seeded document whose content control wraps a whole table row or cell, as repeating-section templates do, now lists that row’s or cell’s paragraphs in body.paragraphs and body.text, and search finds their text for an add-in to edit, tracked or not. The twin had skipped them, though the .docx it wrote back kept them. getOoxml of a paragraph in a table cell now answers that paragraph alone, without the table around it, as Word does.

  16. Save the document your add-in left. office.docx() resolves to the twin’s document as a .docx, revisions and comments included, so a test can compare what the add-in made with what a headless OOXML path makes from the same seed. Two runs that make the same edits save byte-identical files, and the twin serves the same file at GET /_rystic/inspect?view=docx.

  17. Messy documents read as Word for the web reads them. A paragraph’s getOoxml is the paragraph alone, without its paragraph properties and outside any block content control that holds it, and a range inside one paragraph is outside the table and control around it too. text reads a soft or no-break hyphen and a carriage return as nothing, a page break as \f, a footnote reference as ^B and a no-break space as U+202F, and a Wingdings symbol as its character. A cell a vertical merge continues lists no paragraph in body.paragraphs. A Replace of an inline content control’s whole text keeps the new text inside the control, where the twin put it before the control or, untracked, dropped the control. Packages number their bookmarks from 1. Nine messy documents, from PDFs converted to Word to templates full of content controls, are now measured against Word every day.

  18. getOoxml writes a content control around table rows as Word does. Word for the web’s getOoxml writes the rows inside a content control around them, such as a repeating section’s, as they were imported, leaving out text an add-in edited there since, which paragraphs, search and tracked changes still read. The twin’s getOoxml now does the same, so a test of an add-in that rebuilds the body from getOoxml loses those edits as it would in Word. office.docx() and the /_rystic download still hold every edit.

  19. A seeded document reads as Word for the web imports it. A simple field (w:fldSimple) becomes a complex one in getOoxml, as Word’s import writes it, all but a NUMPAGES with a \* MERGEFORMAT switch. A page number field’s result (PAGE, NUMPAGES, SECTIONPAGES) reads as nothing in text, and search neither finds it nor matches across it: Page 1 of 1 reads Page of , as in Word. A document with footnotes or endnotes gains Word’s separator notes and their settings, and an endnote reference reads ^B as a footnote’s does. Each package numbers its relationships from rId1 in Word’s order, and holds a hyperlink’s only when it holds the link. A paragraph’s or a range’s getOoxml writes the content control its span ends in as its text alone, as Word’s does; the body’s keeps it.

  20. Map a range to the body’s paragraphs. Range.paragraphs lists the paragraphs a range touches, as Word does: a range inside a table cell answers that cell’s own paragraph, the one body.paragraphs lists, so an add-in can map a search hit in a table to body paragraph indices. It answered ApiNotFound before.

  21. A date picker can’t be deleted, as in Word for the web. An edit that would delete any of a date picker content control’s text, such as body.clear() or an insertText(…, 'Replace') over it, now answers NotAllowed and leaves the document as it was, so an add-in that clears a template holding one fails on the twin as it does in Word for the web. Text inserted at the start of a range inside one answers NotAllowed too, and deleting a range that only touches an inline one’s edge answers GeneralException.

  22. people.xml names the author of a tracked change who comments. A getOoxml package now names, in people.xml, the author of a revision in it when that author comments anywhere in the document, as Word for the web does, so a paragraph holding only a commenter’s redline carries their person. After Test User’s first comment, its own tracked edits name it too. Persons are listed in the order their authors first appear in the package, and a seeded person whose author never comments is no longer named.

  23. See what a sync count is made of. office.batches() resolves to the batches the twin counted since the last office.mark() or reset, each with every action named by its type and the object it acts on, such as Query Body._Search2(), so a count that comes out higher than expected names the batch that made it so. The twin keeps its latest 128 counted batches since its last reset at GET /_rystic/inspect?view=officejs-batches, and a corpus run’s batch log holds the batches of each test.

  24. Replace writes unformatted text, as Word for the web does. insertText(…, 'Replace') now gives its text no formatting of its own, tracked or not, where the twin had copied the formatting of the text it replaced; replacing a bold phrase writes the new phrase plain. An edit at the end of a paragraph gives its paragraph mark the formatting of the paragraph’s last run, as Word does, so getOoxml and the saved .docx match Word’s.

  25. Text inserted in a cell control inside a row control is refused, as Word does. Inserting text inside a content control around a table cell, when a content control around its row holds that cell, as in a repeating section’s items, now answers NotAllowed and leaves the document as it was, tracked or not, whether through the paragraph, a range in it or the control, as in Word for the web. The twin had made the edit. A control around the cell alone, or around the row alone, still takes it.

  26. getOoxml of a paragraph inside a content control around table rows answers as Word does. Word for the web writes such a paragraph’s package, or a range’s inside one, from the paragraph as it was imported, at the same offsets, and refuses it with GeneralException once the paragraph’s text runs past that copy, as after an add-in appends to it, or when the paragraph was inserted there. The twin now does the same, so a test of an add-in that reads a repeating section item’s getOoxml after editing it meets the error it would meet in Word.

  27. A collection loaded again in one run lists what it first held. As in Word, body.paragraphs, a range’s paragraphs, a search’s hits, comments and a thread’s replies loaded again in the same Word.run list the items Word resolved when the run first reached them, each read as it is now: a paragraph deleted since stays with empty text, one inserted since is not listed, and the deleted paragraph’s own object throws ItemNotFound. A tracked change collection still lists the changes as they are. The twin listed the document as it was at every load before.

  28. getOoxml of a range over table cells writes the package Word does. A range that starts or ends inside a table cell now answers a package holding only the rows it reaches, the row it starts in from the cell it starts in. A range that ends inside a cell writes what it covers of that row as a table of its own, then the paragraph it ends in as a body paragraph after the tables, as Word for the web does. The twin had written the whole table, with the cells outside the range left blank.

  29. getOoxml writes a date picker as Word for the web does once text goes in at its start. After your add-in inserts text at an inline date picker’s start from outside it, the body’s and the paragraph’s getOoxml write the picker in two, an empty control without its date and then one with a new id holding that text and the date, as Word for the web does, so an add-in that reads date pickers back from the package sees on the twin what it sees in Word.

  30. Deleting or replacing part of a paragraph’s text inside a row control is refused, as Word does. With deletions untracked, Range.delete of part of a paragraph’s text inside a content control around table rows, such as a repeating section’s, and insertText with Replace over part of it now answer GeneralException and leave the document as it was, as in Word for the web; the twin had made the edit. A delete from part of one paragraph into the next there takes only the text of the paragraphs it holds whole. A paragraph’s whole text, inserted text, Paragraph.delete, insertOoxml and tracked edits go through.

  31. Deleting the document’s last paragraph empties it, as Word does. Paragraph.delete() on the document’s last paragraph, or on a table cell’s, now takes only its text and leaves the paragraph, empty, as in Word for the web, so body.paragraphs still lists it and the document still ends in a paragraph. Tracked, the paragraph’s text is marked deleted and its paragraph mark is not. The twin had removed the paragraph, and after a table it had left the document ending in the table.

  32. Deleting a content control’s only paragraph removes the control, as Word does. Paragraph.delete() on the only paragraph of a block content control now takes the control with it, as in Word for the web, so the body’s getOoxml and the saved .docx no longer hold an empty control there; a paragraph that ends a table cell stays, empty, in the control’s place. Tracked, getOoxml writes the control’s edges as deleted, w:customXmlDelRangeStart and w:customXmlDelRangeEnd pairs, as Word does, and so does any tracked delete that takes a block control whole, such as body.clear(). The twin had emptied the paragraph and kept the control.

  33. A deleted comment in a collection loaded again refuses its content, as in Word. A comment or reply your add-in deletes stays in a comment or reply collection the same Word.run reached before, with its id, author, date and resolved state, but reading its content, or loading the collection’s items with every property, now throws GeneralException, and the deleted comment’s own object throws ItemNotFound, as in Word for the web. The twin had read the deleted comment’s content.

  34. A group content control is locked, as in Word for the web. A document holding a group content control now imports it locked: an edit to text in its paragraphs, such as insertText or delete through a paragraph or a range, or a delete across the group’s edge, answers NotAllowed and leaves the document as it was, tracked or not. The twin had made the edit. A rich-text control inside the group still takes edits, and body.clear(), a Replace of the whole body and a delete of a range around the group take it with them, its paragraphs too.

  35. A tracked Replace of the body writes its text untracked, as Word does. With tracking on, body.insertText(…, 'Replace') now marks the old text deleted and writes the new text before it with no insertion, so getTrackedChanges lists only the deletions, as in Word for the web. The twin had tracked the new text as an insertion. A Replace through a paragraph or a range still tracks it.

  36. A range inside a deleted paragraph goes with it, as in Word. Once an untracked delete() removes a paragraph, a range a search or getRange made inside it in an earlier context.sync() that only read throws ItemNotFound for the rest of the run, as the paragraph does. A range made in the same sync as the delete, or by expandTo or insertText, still reads empty at the start of the next paragraph, and a range that runs into the deleted paragraph now ends where the text before it ends. The twin had read every such range where the paragraph collapsed.

  37. Deleting a cell’s only paragraph in a one-row table takes its column, as Word does. In a one-row table, or for a cell merged down every row, an untracked Paragraph.delete() of the cell’s only paragraph, or Range.delete() of the whole cell, now removes the cell and its column, and the table with its last column, as in Word for the web; the twin had emptied the paragraph. Deleting a paragraph that holds a comment’s anchor now removes the comment with it; the twin had failed the request.

  38. getOoxml writes a package’s last paragraph without its properties, and text at a paragraph’s end takes its formatting as Word does. Every getOoxml package now writes its last paragraph without its alignment, style, numbering or mark formatting, as in Word for the web, which still holds them: the body’s leaves out the document’s last paragraph’s, and a range’s those of the paragraph it ends in. Text inserted untracked at a paragraph’s or the body’s End takes the formatting of a paragraph mark that has its own; with tracking off, an edit at a paragraph’s end gives its mark the last run’s formatting; insertParagraph gives the new paragraph’s mark no formatting of its own, takes it off the paragraph’s own mark with After though not with Before, and gives the new text the formatting of the run beside it; and text typed into the document’s emptied last paragraph takes the formatting of the run deleted. The body’s insertText at Start or End now writes untracked text with tracking on, as its Replace does. The twin had written every paragraph’s properties and tracked that text.

  39. An insertOoxml puts a content control around table rows back as getOoxml writes it, as Word does. In Word for the web, an insertOoxml anywhere in the document, at any location, first gives each content control around table rows, such as a repeating section’s, the rows its getOoxml writes: what an add-in changed inside one since goes, text inserted, deleted or replaced, a paragraph emptied or inserted, and tracked edits with their tracked changes. The insertOoxml’s own edit stays, inside the control too, and the body’s getOoxml writes it from then on; into a paragraph there, it lands at the same offsets in the text getOoxml writes, and answers GeneralException when it ends past that text. The twin now does the same; it had kept those edits, and written the control as first imported.

  40. A delete or Replace across a table cell’s edge is refused, as Word does. Range.delete() and insertText(…, 'Replace') of a range that runs from one table cell into another, or into or out of a table, now answer GeneralException, tracked or not, and leave the document as it was, as in Word for the web, unless the range runs from the table’s first cell, or before it, into its last cell or past it; the twin had deleted the text in each cell. Untracked, such a delete that holds the table’s start or end now takes the cells it holds whole, and the table with its last; a Replace deletes the same way before it writes its text; and a tracked delete no longer marks a cell’s ending paragraph mark deleted.

  41. Accepting the delete of a content control’s only paragraph leaves the control inline, as Word does. Accepting a tracked Paragraph.delete() of the only paragraph of a block content control, one change or acceptAll(), now joins that paragraph to the next and leaves the control there, empty and inline at the next paragraph’s start, as in Word for the web, so body.paragraphs lists one paragraph fewer and the body’s getOoxml writes the control inside the next paragraph, its edges still marked deleted. Accepting or rejecting any change that takes the mark of a control’s only paragraph, such as a seed’s deletion or insertion, does the same. Accepting the deleted mark of the paragraph just before such a control joins the two the same way, the control inline around its text. A control that ends a table cell, or that a table follows, keeps its paragraph, empty. The twin had kept the control as a block holding an empty paragraph.

  42. Just Mine tracks your edits as For Everyone does, but for insertOoxml. Under changeTrackingMode = TrackMineOnly, the text a paragraph’s or a range’s insertText puts in, and the mark of the paragraph body.insertParagraph adds (not its text), are now tracked, as in Word for the web; the twin had tracked only deletions. insertOoxml now tracks nothing in that mode: a Replace deletes the old text untracked and the package keeps its own revisions, as with tracking off.

  43. Deleting a paragraph’s whole range removes the paragraph, as Word does. With tracking off, delete() on a range that holds a paragraph whole, such as paragraph.getRange('Whole'), now removes the paragraph as its own delete() does, and a range over several paragraphs removes each one it holds whole, where the twin had emptied them and kept their paragraph marks. A range a search or getRange made inside such a paragraph in an earlier context.sync() now throws ItemNotFound. Tracked, the mark that ends the document or a table cell is no longer marked deleted, and a range deleted through the document’s last mark throws ItemNotFound afterwards, as in Word for the web.

  44. A range delete around a block content control keeps the paragraphs either side, as Word does. With tracking off, delete() on a range from inside the paragraph before a block content control to inside the paragraph after it now takes the control and keeps both paragraph marks, as in Word for the web, so the text left either side stays in two paragraphs. This holds for a control of any kind, a group content control included, for nested controls and for several controls in a row. The twin had joined the two paragraphs into one.

  45. A range keeps its offsets when its text shrinks, as in Word. With tracking off, once an untracked delete shortens a paragraph’s text, a range a search or getRange made there in an earlier context.sync() that only read reads no further than the text’s end, where the twin had read the paragraph mark too, and a range over text the delete emptied answers isEmpty false while it reads empty. Text inserted at such a range past the text lands at the text’s end, not at the start of the next paragraph, and an end past the mark, as getRange('Whole') has, stays past it as the text grows or shrinks.

  46. An insertOoxml Replace across paragraphs joins them, across table cells too, as Word does. With tracking off, insertOoxml(…, 'Replace') over a range that runs across paragraph marks now takes everything between, a block content control included, and joins the text before and after the range around the package, as in Word for the web. Across table cells it joins the cells and rows it crosses into the first cell it reaches, a shorter row skipping the rest of the grid with gridAfter, and over exactly one table it takes the table and joins the package to the paragraph after. Under TrackMineOnly it joins the same way, and under TrackAll it now changes nothing, as Word’s reads show. The twin had kept every paragraph and cell, and tracked the edit.

  47. A range moves with the text unless a sync that only read made it, as in Word. A range a search or getRange made in a context.sync() that only read still keeps its offsets in its paragraph through later edits, but every other range now follows the text: one made in a sync that wrote anything, such as an edit anywhere or a changeTrackingMode set, one made in the edit’s own sync, one insertText answered, and one an insertText or delete went through. Replacing every hit of a search made in a sync that wrote now lands each replacement on its own hit, and such a range inside a paragraph a later untracked delete() removes reads empty at the start of the next paragraph rather than throwing ItemNotFound. insertOoxml of a one-paragraph package moves no range but its own. The twin had kept every range at its offsets, but a tracked insertion’s own search hits.

  48. A Replace across paragraphs deletes as delete() does, as Word does. With tracking off, insertText(…, 'Replace') over a range that runs across a paragraph mark now deletes as delete() does, taking any block content control between and keeping the paragraphs either side apart, then writes its text where the range collapsed; a paragraph mark the range ends just past stays. The twin had kept the controls and an empty paragraph for each paragraph between. With tracking on, the new text now goes after the deleted text, in the paragraph the range ends in, as in Word for the web, where the twin had written it at the range’s start. A delete or Replace across a paragraph mark also gives the marks it leaves the formatting of the text it took before them.

  49. Spaces side by side read as Word for the web reads them under tracking. Once an edit with tracking on changes a paragraph’s text, text reads each run of spaces in it plain and no-break (U+00A0) in turn, a no-break space first at the paragraph’s start: insertText, insertParagraph, a tracked delete of your own insertion, and an accept or reject that takes text out. Before, the twin read plain spaces where a tracked insertion left two side by side, and read the second of two an accept or reject left as a no-break space with tracking off too. getOoxml and search keep plain spaces.

  50. A paragraph a package of several paragraphs splits keeps its ranges in its last part, as in Word. After insertOoxml of a package of several paragraphs, or one holding a table, into a paragraph, the Paragraph object, and every range in it but the one the insert went through, reads the last part, the one holding the paragraph’s mark, at the same offsets, so a search hit after the insertion point reads the text now at its offsets there. A tracked change listed before reads the text at its offsets there too, and settles by them. The twin had kept such ranges in the first part, and settled such changes as listed.

  51. An insertOoxml reads every paragraph’s spaces as Word for the web does. Once an insertOoxml goes through, tracking on or off, text reads every paragraph in the document afresh: a space that starts a paragraph, ends it or is followed by another reads as a no-break space (U+00A0), every other as a plain space. Before, the twin left each space reading as it did. getOoxml and search keep plain spaces.

  52. A tracked insertOoxml Replace across a paragraph mark answers the range Word does. Under TrackAll, insertOoxml(…, 'Replace') over a range across a paragraph mark still changes nothing the reads show, and now answers a range from the range’s start to the start of its last paragraph, or over exactly one table to its end, as in Word for the web; the range it was called on reads the same. Word’s own getOoxml writes a half-applied edit after such a Replace, which its reads show only once that package is read; the twin follows the reads and writes the document as it was.

  53. A paragraph break inside an inline content control makes it a block control. insertText with a newline, or insertOoxml of several paragraphs, through a range inside an inline rich-text content control now turns the control into a block control around the paragraphs it then spans and splits the paragraph at the control’s edges, as Word for the web does; contentControls lists it once with those paragraphs’ text, and a locked control refuses the split with NotAllowed. Before, the twin kept the control inline, in two halves with one id.

  54. A package of several paragraphs at a Whole range’s End reads as in Word. insertOoxml of a package of two or more paragraphs at the End of a paragraph’s getRange('Whole'), whose end is past the paragraph mark, splits the paragraph and leaves the range ending at the last part’s text, short of the mark, where the twin had read the mark too. A range that kept its offsets, one a context.sync() that only read made, then reads one paragraph mark more after the first part, …a\r\rb for a package of a and b, as Word for the web does, though the document holds no paragraph there, until an edit goes through it, and so do the Whole and Content ranges getRange takes from it; a range that moves with the text reads …a\rb. A package that starts with a table leaves the range over the package’s content.

  55. body.clear() and the body’s Replace keep the last paragraph, as in Word. With tracking off, body.clear() and body.insertText(…, 'Replace') now remove every paragraph but the document’s last, which stays and takes the new text: a body.paragraphs reached before reads the others empty and the last with the new text, a range a search or getRange made in a removed paragraph in an earlier context.sync() that only read throws ItemNotFound, the body’s getRange('Start'), Content and Whole included, and any other range collapses to the start of the paragraph that stays, where after a Replace one from a removed paragraph reads the new text, answers isEmpty true and takes an insert after that text. The twin had kept the first paragraph, read every range in the others as empty and taken text inserted at them silently.

  56. acceptAll and rejectAll stop at a joined paragraph, as Word does. They now settle a collection’s changes in the order it lists them, and once one takes a paragraph mark out, joining that paragraph to the next, a change listed in the next paragraph has lost its paragraph: a text change settles nothing and stays listed, and a change holding that paragraph’s mark answers GeneralException, which ends the call with it and every later change still tracked, as in Word for the web; a single accept or reject of such a change answers ItemNotFound. So rejectAll over two adjacent inserted paragraph marks, such as two insertParagraph calls at the body’s End or the pair a Range.insertParagraph split leaves, rejects the first and refuses the second, and acceptAll over two adjacent deleted paragraphs accepts the first and refuses the second. Two text changes in one paragraph settle in that order too, the second keeping the end it was listed with, so rejectAll over an insertion and a later Replace in one paragraph now removes the replaced text and what follows it, as Word does. Accepting one author’s deletion of a paragraph mark once another author’s deletion of that paragraph’s text was accepted now answers GeneralException and drops the mark’s deletion, the paragraph staying in place, as in Word. The twin had settled every change, last first, and joined the paragraphs.

  57. An insertOoxml Replace into or out of a block content control joins and leaves the control inline, as Word does. With tracking off, insertOoxml(…, 'Replace') over a range that starts or ends inside a rich-text block content control’s paragraph now joins the paragraphs around the package as in Word for the web: a control left holding only that paragraph goes inline around what is left of its text, empty when nothing is, or goes when the range held it from its start, while a control that keeps other paragraphs stays a block and keeps the text after the range apart. The package lands inside the control the range began in, past any spaces that ended the control’s text, which leave it, as they do for an inline control; a package that would land inside a plain-text control is refused with NotAllowed. The twin had kept the control’s paragraph apart.

  58. A range that moves follows its text into the rest of a paragraph Range.insertParagraph splits. As in Word for the web, when insertParagraph through a range splits a paragraph, a range made in a context.sync() that wrote, or by expandTo, now follows its text: a hit past the split reads its text in the rest, a hit across it reads across the new paragraph, and a span from it into the next paragraph starts in the rest. A range a sync that only read made keeps its offsets in the first half, as before. The twin had kept every range but the one the insert went through in the first half.

  59. A document read in reads its spaces as Word for the web does. Before any edit, text reads a space that starts a paragraph, ends it or is followed by another as a no-break space (U+00A0), every other plain, as an insertOoxml later reads them; before, the twin read plain spaces until an insertOoxml. Every other character keeps spaces apart, a field whose result reads as nothing or a drawing too, and so does each edge of a content control, an empty one too, or a hyperlink: a space at a control’s edge, or the first or last of a paragraph a block, row, cell or table control holds, reads plain, after an insertOoxml as well. search matches a no-break space, in the search text or the document, with a plain space.

  60. An edit from inside a block content control’s last paragraph through its mark ends the control where it started. As in Word for the web, an untracked Range.delete from inside the paragraph’s text through its mark, or of that mark alone, and a Range.insertText(…, 'Replace') whose range ends just past the mark, leave a control holding that one paragraph inline around the text before the edit, plus the text the Replace wrote in place of what it took, so text inserted at the range afterwards lands after the control; a control holding more paragraphs stays a block and that text starts a new paragraph after it. Under TrackAll, a Replace from before the control that writes after the deleted text at the control’s end does the same and marks the control’s edges deleted. The twin had kept the control a block holding the inserted text.

  61. A tracked insertOoxml Replace over a paragraph’s Whole range, or from a paragraph’s start across its mark, now goes through as Word does. Under TrackAll, insertOoxml(…, 'Replace') over a range that ends just past a paragraph mark deletes short of that mark, which stays, and one from a paragraph’s start across marks lands as a tracked edit, the package at the range’s start and the text to the last paragraph’s start marked deleted; only a range from inside a paragraph’s text across a mark the document had, or over the body’s or a table’s whole text, still changes nothing. A tracked package of two or more paragraphs now tracks the marks it adds and its last paragraph’s text, never the text before them, so a later tracked deletion over that text is a plain deletion, and an insertOoxml Replace’s deletion takes over a deletion already in its range. The twin had tracked every paragraph’s text, removed the first paragraph’s under a later Replace, merged the paragraphs and refused the paragraph after.

  62. An insertOoxml Replace across paragraph marks keeps the last paragraph’s object, as in Word. After an untracked insertOoxml(…, 'Replace') over a range across one or more paragraph marks, the Paragraph object of the paragraph whose mark ends the range reads the joined paragraph, and every range in it, a search hit from a sync that only read or one that wrote, keeps its offsets in the joined text; the paragraphs the range starts in and crosses are gone, so their objects, every range in them, every range running into them and every tracked change listed in them answer ItemNotFound. Across table cells the paragraph of the cell the range ends in keeps its object, and over a whole table the paragraph after does. The twin had kept the first paragraph’s object and shifted the last’s ranges into it.

  63. Text inserted right after a tab is a tab run in getOoxml. As in Word for the web, text your add-in inserts right after a tab under tracking is written by getOoxml, the body’s, the paragraph’s and the insertion’s own, as one w:tab whatever it holds, a space, two, a letter, or a letter and a space, each stretch between tabs it holds as one, through Before or Start but not a Replace, and still once the insertion is accepted; text and search read the text and the twin’s saved .docx keeps it. Before, the twin wrote the text.

  64. A package that starts with a table adds no empty paragraph at a paragraph’s start, as in Word. insertOoxml of a package whose first block is a table, at a paragraph’s start, through the paragraph, the body or a range there, at any location, tracked or not, puts the table right before the paragraph, which keeps its object, its mark and every range in it, and a table already right before it, of the same layout and table style, takes the package table’s rows, each keeping in a w:tblPrEx the alignment, borders, shading, cell spacing and margins its own table differed in, as Word for the web does. The twin had split off an empty paragraph before the table. A range a table-first package went through at its Start now starts at the table, there or inside a paragraph’s text, T1\rbbbb for a hit on bbbb, where the twin had started it at the paragraph mark before the table, and a collapsed range it went through at its End grows over it the same way. A range that ends where a package ending in a table ends now ends past the table’s last cell mark, so text inserted at its End lands in that cell, where the twin wrote it at the start of the paragraph after; and tables side by side in a package join as one, where the twin kept them apart.

  65. A paragraph’s After range sits at the next paragraph’s start, and a Whole range’s End at the text’s end. As in Word for the web, text your add-in inserts through a paragraph’s getRange('After'), or the After of its Whole range, lands at the start of the paragraph after it at every location, Replace included, in a table’s first cell or the next cell too, and the range stays there when a paragraph goes in between. Through getRange('Whole').getRange('End') it lands at the paragraph’s text end, as through getRange('End'), taking the paragraph mark’s formatting at Start. The After of the last paragraph’s or the body’s Whole range is a null object, and a span expandTo ends at the next paragraph’s start holds the paragraph mark, so text inserted at its End lands before the mark. Before, the twin held both ranges just past the mark and wrote at the text’s end.

  66. An insertOoxml puts back each range your add-in has not reached since it moved, as in Word. Word settles a range’s place when your add-in makes it, reads it, calls a member on it, passes it as an argument, takes it with getFirst or edits through it. An insertOoxml anywhere, tracked or not, now puts every other range that moves with the text back where it sat then, so a hit after text insertText added, or after text a delete, an accept or a reject took, reads its old offsets again. A range a Paragraph.delete collapsed to the next paragraph’s start throws ItemNotFound once an insertOoxml lands in that paragraph, and one that ran out of, into or across the deleted paragraph, or that the document’s last paragraph held, throws it after any insertOoxml, until your add-in reaches it again. A package inserted through such a collapsed range at Start or End is now held by the range. The twin had kept every range where the edits moved it.

  67. A change listed before a split settles by its offsets in the part holding the mark, as in Word. After insertOoxml of a package of several paragraphs splits a paragraph, accepting or rejecting a tracked change listed before settles what its offsets now hold there. Accepting an insertion, rejecting a deletion or settling a formatting change settles each change of its kind whose text lies wholly inside them, and rejecting an insertion or accepting a deletion takes out the text there, short of where its mark was, which the change’s text also stops at. A paragraph mark inside them has its revision of the change’s kind settled too, whoever made it, and one of another kind left alone; where that settle would take the mark out, the mark stays with the revision dropped, unless the change held its mark at that very offset, where the paragraph joins the next. Rejecting an insertion, or accepting a deletion, that held its mark elsewhere also answers GeneralException. A change settled before, split or not, likewise reads and takes out its text short of where its mark was, where the twin had reached one character past it. The twin had settled none of the text such a change’s offsets held and left the mark as it was.

  68. A tracked insertOoxml now tracks the tables in its package. As in Word for the web, under TrackAll each row of a package’s table and each cell’s paragraph marks and text are marked inserted, each cell paragraph listing as an Added change, a package that starts with a table leaves the mark it splits off untracked, and the insertions and deletions a package itself carries give way to the insert’s own tracking; the twin had put the tables in untracked and kept the package’s revisions.

  69. A slow client no longer holds up the others. The simulator reads a request’s whole body before it starts serving it and sends the answer after, so a client that uploads slowly, or reads a large answer slowly, no longer delays other clients’ requests. Answers are unchanged; one larger than 4 MiB still makes other requests wait while it is sent.

  70. The call log shows what each call asked for and whether it succeeded. Each GET /_rystic/calls entry now carries args, the call’s query and body arguments with credentials redacted (long values cut, file contents left out), and ok, false for an answer that is not 2xx. An answer carrying an Error object at HTTP 200 is not ok either, and its code is the entry’s error. A path the simulator does not serve is marked unknown_route: true. ?tail=N returns only the last N calls, and dropped counts the calls the log no longer keeps.

  71. The docs are one page, with a page per feature. The archive ships README.md in place of HOWTO.md, with the same sections in the same order as every Rystic simulator’s page, and CHANGELOG.md beside it. Tracked changes, comments, the document view, the saved .docx, the corpus runner, content controls, tables, ranges, text edits, spaces and OOXML each have their own page under docs/features/, and every example that talks to the simulator over HTTP runs as written against a fresh simulator.

  72. The docs page links the admin token section. The page now sends you to the admin token’s section of the shared-simulators page.

  73. The Scenarios table is easier to scan. Each row says in a line what the scenario is for, and a list below the table says what each scenario holds.

  74. Pro starts with a 14-day trial. Free has ended, so every simulator is on Pro and Enterprise, and the docs name only those plans.

  75. Limitations is shorter to read. It opens with the known issues and one sentence counting the rest. The detailed lists follow, and the website folds them behind “Show all limitations”.

  76. The page has no Plans section. The Quickstart names the plans that run the simulator, and the pricing page lists what each plan includes.

  77. The docs read more plainly, and insertOoxml has two new pages. Sentences are shorter, and each behavior that differs from Word for the web is marked “Differs from Word for the web” where it’s described. Rules with several outcomes, such as what a delete leaves of each range, are tables. How insertOoxml moves ranges, and what its Replace does across paragraphs, move to new pages that each open with an example. Seeding state and Determinism and reset link the shared pages for the seed, clock and reset rules every simulator follows, and Auth, Seeding state and Determinism and reset keep only what is particular to Word.

v0.0.1 — 2026-10-14

  1. A local Word host for your Office.js add-in’s tests. The first release of the Word twin: your add-in loads Microsoft’s office.js from its CDN, unchanged, and the twin plays Word for the web to it, answering each context.sync() as Word does across 86 members of Word’s API — the body, paragraphs and ranges, search and edits, change tracking, tracked changes, reviewed text, comments and OOXML — from a model validated against live Word for the web. Your suites run with no Microsoft 365 account, no sideloading and a fresh document for every test.

  2. The harness: Jest, Vitest and Playwright suites against the twin. The twin serves @rystic/officejs-harness, MIT-licensed, at /officejs/harness.tgz, so npm install --save-dev http://127.0.0.1:8080/officejs/harness.tgz installs the version that matches it. In Node, connect() loads office.js into a jsdom window whose host is the twin, install() puts Office, OfficeExtension and Word on the global object, and reset() starts a fresh document. In a browser, the twin’s host page /officejs/host?addin=<url> frames your task pane: openAddin() routes it inside a Playwright context with no certificate, and officejs-harness serve fronts it over https on localhost.

  3. Read, search and edit the document as Word.run sees it. context.document.body, its paragraphs and their ranges answer text, getFirst, getLast, items and getRange; search on a body, paragraph or range takes office.js’s options (matchCase, matchWholeWord, matchPrefix, matchSuffix, matchWildcards, ignorePunct, ignoreSpace); and insertText at each location Word takes, Body.insertParagraph, Body.clear and delete edit it. The handshake declares WordApi 1.6 and WordApiSearchFlags 1.1, so isSetSupported answers for what the twin serves, and a member it does not serve answers Word’s ApiNotFound. A reach that finds nothing raises Word’s ItemNotFound from context.sync().

  4. Tracked changes, reviewed text and comment threads. Setting changeTrackingMode to TrackAll or TrackMineOnly makes every edit a tracked insertion or deletion; getTrackedChanges on a body, paragraph or range lists them with type, author, date and text, and accept, reject, acceptAll and rejectAll resolve them. getReviewedText('Current') and ('Original') read the text with and without the pending changes. Range.insertComment anchors a comment, getComments lists them, reply adds to a thread, setting resolved resolves it and delete removes it. getOoxml answers the flat-OPC package Word answers, with the redline as w:ins and w:del, and insertOoxml places one.

  5. Contracts to start from, and your own. --scenario word_contract_redlined opens a mutual NDA mid-negotiation, with Alice’s and Bob’s tracked changes, Alice’s comment and Bob’s reply, and a resolved comment; word_contract_clean opens the same NDA with every change accepted, to redline from scratch; SCENARIOS.md says what each is for. POST /_rystic/seed opens your own contract from a base64 .docx or its OOXML parts, keeping its revisions, comments, authors and dates, and POST /_rystic/reset opens a blank document. The clock never moves by wall time, so two identical runs give byte-identical OOXML. /_rystic/ui shows the open document as Word’s review view does, redrawn as each context.sync() lands.

  6. Measured against live Word for the web. The full-suite round of 2026-10-14 (20261014T014904Z) matched 74 of 86 probes against Word for the web, none inconclusive. HOWTO.md states the differences this release ships with: a new comment’s or reply’s id is a fixed number and its creationDate the twin’s clock; getTrackedChanges on getRange('Whole') lists an insertion Word leaves out; Paragraph.insertOoxml takes a package Word refuses; and search with a plain options object, which office.js never sends, finds hits where Word refuses it.

Last updated on