# Word changelog

What changed in each Word simulator release, newest first.

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.

---

Source: https://www.rystic.ai/docs/simulators/word/changelog · Markdown: https://www.rystic.ai/docs/simulators/word/changelog.md
