Zum Inhalt

The editing engine

Schreibzeug edits documents with sdkjs, the JavaScript engine of Euro-Office (a fork of ONLYOFFICE). It runs entirely in the browser; there is no Document Server. Only packages/editor-core talks to it.

Where it comes from

engine/engine.json pins one official Euro-Office Document Server release. bun run engine:prepare (engine/scripts/prepare.ts, also run by bun run dev) downloads that package, checks its SHA-256, and builds engine/dist, showing each step and the download's progress:

Path What it is
sdkjs/{common,word,cell,slide,pdf} the built engine, as shipped in the release (pdf holds the PDF renderer drawingfile.wasm; the PDF editor's code is part of word)
sdkjs/common/AllFonts.js, fonts/ the font index and web fonts, generated by the release's allfontsgen from its open-source fonts (core-fonts)
sdkjs/slide/themes/ the presentation themes (themes.js and one folder per theme), generated by the release's allthemesgen from the theme files it ships, as its install script does (documentserver-generate-allfonts.sh)
vendor/xregexp/, vendor/jquery/ globals sdkjs uses without loading them (jQuery: the text editor's page layout)
x2t/ the converter: ONLYOFFICE core's x2t compiled to WebAssembly (cryptpad/onlyoffice-x2t-wasm), pinned in engine.json
frame/x2t/worker.js the worker that runs x2t (from engine/frame)
frame/editor/<editor>/main/index.html the pages that host the engine (from engine/frame)
dictionaries/<lang>/ Hunspell dictionaries for the spell checker, with their licenses (below)
licenses/ engine and font licenses

allfontsgen is a Linux x86_64 binary, so preparing the engine needs Linux (CI does it and caches the result by the hash of the spec, the scripts and the frame pages).

How it is loaded

sdkjs resolves its own files relative to the page as ../../../../sdkjs/… and ../../../../fonts/ (common/libfont/loader.js, common/GlobalLoaders.js, common/zlib/zlib.js). The host page therefore sits four folders below the engine root: <engine>/frame/editor/<editor>/main/index.html (<engine> is /engine/<id> in the build, see below), with word for text documents, cell for spreadsheets, slide for presentations and pdf for PDFs (PDFs).

mountEngineFrame loads that page in a same-origin iframe and checks that the editor's API constructor is there (asc_docs_api for text and presentations, spreadsheet_api for spreadsheets). The iframe keeps the engine's globals (Asc, AscCommon, AscFonts, …) out of the app and gives each document its own engine.

The iframe has its own Object, Array and so on. sdkjs sometimes checks values with instanceof (CNumInfo.Parse takes an object only if value instanceof Object), which fails for objects made in the app's window. So everything handed to the engine is a primitive, JSON text, or an object made with the frame's own constructors (new win.Asc.asc_CColor(…)).

Opening fast, on a phone too

The engine is large: for a text document the browser loads about 68 MB (x2t.wasm 35 MB, word/sdk-all.js 18 MB, fonts.wasm, the fonts the document uses). Four things keep that from happening on every open:

  • Kept for good. The build puts the engine in a folder named after the prepared engine, /engine/<id>/ (the start of the stamp engine:prepare writes to engine/dist/.version, a hash of the release, the scripts and the frame pages; apps/web/engine-plugin.ts), and the app loads it from there (import.meta.env.ENGINE_PATH, apps/web/src/editor/engine.ts). A new engine gets a new name, so the server sends the engine and the app's own scripts (/assets/, named by their content) with Cache-Control: immutable for a year; everything else, index.html first, with no-cache and an ETag, which the browser asks about again and gets 304 while it is unchanged (apps/server/src/web.ts). Opening a document a second time downloads nothing of the engine. Dev and preview serve the engine at /engine/ with or without the name.
  • Compressed. The server compresses what shrinks (scripts, JSON, WebAssembly, the engine's fonts) with Brotli where the browser takes it, else gzip, once per file, and keeps the result in memory (apps/server/src/compression.ts): about 15 MB instead of 68 on the first open. Brotli at quality 5: x2t.wasm takes about a second, quality 11 would take two minutes. The desktop app's local server sends files as they are; they never leave the computer.
  • At the same time. The file is converted while the frame loads: convertForEngine starts x2t as soon as the editor mounts, so x2t downloads, compiles and converts in its worker while the frame parses sdkjs, and the editor's open function takes the result as converted.
  • Compiled while it downloads. The worker hands x2t.wasm to emscripten through instantiateWasm with WebAssembly.instantiateStreaming (engine/frame/x2t/worker.js, the hook in x2t.js createWasm), not as bytes (wasmBinary): it compiles while it arrives, and Chromium keeps the compiled code beside the cached file, so the next open does not compile 35 MB again.

Code in documents does not run

Being same-origin, the frame acts with the signed-in user's session. sdkjs can run code a document carries, in closures that hide window and document but not parent, top, fetch or location (common/macros.js _safe_eval_closure, pdf/src/forms/formActions.js EvalScript). Schreibzeug has no macro editor and no plugins, so src/document-scripts.ts switches all of it off:

  • JS macros (text, spreadsheets, presentations): createDocInfo sets put_IsEnabledMacroses(false), which asc_runMacros (a click on a shape or button bound to a macro) and asc_runAutostartMacroses check (common/apiBase.js).
  • Custom functions of a spreadsheet: the engine evaluates those a file brings on open (cell/api.js _openDocumentEndCallback → registerCustomFunctionsLibrary, cell/api_plugins.js) without asking the macro switch, and a co-editor's change can set new ones (SetCustomFunctions). withoutCustomFunctions replaces that method on the API object.
  • JavaScript actions of a PDF (a field's format, keystroke and calculation scripts, link actions): CActionRunScript runs them with no switch at all. silencePdfScripts lets each action run as an empty script, right before the engine reads the PDF (beforeOpening wraps openDocument: the PDF code is in sdk-all.js, which sdk-all-min.js loads only after the API exists, common/editorscommon.js loadSdk). The action queue goes on as before and the script stays in the file; a field formatted by a script (AFNumber_Format) shows the plain value.

sdk-all.js cannot be left out of engine/dist for this: every editor loads it. Instead engine/scripts/prepare.ts (checkScriptHooks) counts the places these switches rely on in the built bundles and stops when an engine update moves one.

Why a converter

The engine opens and saves its own binary format ("DOCY", "XLSY", "PPTY"). Reading and writing OOXML directly is an optional addon (Asc.Addons.ooxml, CDocument.fromZip) that is not part of the Euro-Office build: setting the flag only moves the failure to fromZip is not a function. So every open converts the .docx to the binary format with x2t, and every save converts back, as CryptPad does. x2t runs in a worker (createConverter), so its large heap stays out of the page.

How a document opens without a server

Opening is the same for every editor (src/document.ts openDocument); each editor adds its part as an EditorBinding: the format, how its API is made, its state and events, its commands, what it sets once the document is shown. openWordDocument (src/word.ts), openSheetDocument (src/cell.ts) and openPresentation (src/slide.ts) are the bindings. The flow converts the file (docx → bin, xlsx → bin, pptx → bin) and then follows the mobile editors of web-apps (apps/*editor/mobile/src/controller/Main.jsx):

  1. The editor's API inside the frame: new Asc.asc_docs_api({"id-view": "editor_sdk"}), or new Asc.spreadsheet_api({"id-view": "editor_sdk", "id-input": "ce-cell-content"}).
  2. Register the callbacks (state events, asc_onGetEditorPermissions, asc_onDocumentContentReady, asc_onError).
  3. asc_setDocInfo(info) with the URL AscCommon.offlineMode. That switches off the server connection: _coAuthoringInit keeps the co-authoring URL empty, CDocsCoApi.init then answers with a local "first connect" and an empty license instead of opening a socket (common/apiBase.js, common/docscoapi.js). In a shared session the URL is a blob URL of the converted document instead, and window.io in the frame is a socket.io stand-in that talks to the Schreibzeug relay; see collaboration.
  4. asc_getEditorPermissions(); the permissions callback calls asc_LoadDocument().
  5. In offline mode sdkjs asks for the file through _openChartOrLocalDocument, which would open an empty document. Schreibzeug replaces it on the API instance with asc_openDocumentFromBytes(binary).
  6. asc_onDocumentContentReady → the editor is ready.

How it saves

asc_nativeGetFile() (word/api.js) returns the document in the binary format as DOCY;v5;<size>;<base64>; x2t converts it to .docx (bin → docx). The web app stores the result on the server as a new version (PUT /api/files/:id/content, see files) and then calls markSaved(saved), which is SetUnchangedDocument(): "modified" turns false until the next change. In a shared session it also tells the relay how much of the change log the stored copy holds; see collaboration.

Mail merge

"Serienbrief" in the text editor: a letter with merge fields and a table of recipients become one new document with a letter per recipient, each after a page break, stored beside the letter as " – Serienbrief.docx" (apps/web/src/editor/use-mail-merge.tsx).

  • Recipients: "Empfänger wählen …" takes a table from the user's files or from the computer: .xlsx (its first sheet, read by packages/ooxml xlsxRows, dates and numbers as German text), .ods and .xls (turned into an .xlsx by x2t first) or .csv (decodeCsv in src/csv.ts; UTF-8 or Windows-1252, as German Excel writes it). The first row names the fields; a column without a name or a table without recipients is refused with the reason. The engine gets them as a list (word/api.js asc_StartMailMergeByList); they live in the session, not in the document.
  • Fields: the bar above the letter puts a field at the cursor (asc_AddMailMergeField, a MERGEFIELD showing «Name», which Word reads too) and names the fields of the letter that the table lacks. Word writes merge fields as complex fields (w:fldChar … MERGEFIELD Name … w:fldChar); the engine reads those but merges only simple ones (word/Editor/Document.js Register_Field → m_oMailMergeFields), so a .docx has its complex merge fields made simple (w:fldSimple, same instruction and result) before it opens (packages/ooxml simplifyMergeFields).
  • Preview: shows the letter for one recipient (asc_PreviewMailMergeResult); the engine does not let the letter be edited meanwhile.
  • The letters: CDocument.Get_MailMergedDocument copies the letter once per recipient with the fields replaced. The engine's own download sends that to the Document Server; here it is written in the binary format (AscCommonWord.BinaryFileWriter, as asc_nativeGetFile) and turned into a .docx by x2t with the letter's images (src/word-mail-merge.ts).

e2e/mail-merge.spec.ts merges test-files/einladung.docx (Word's merge fields) with test-files/empfaenger.xlsx, adds a field, steps through the preview and reads the stored letters; a second test takes a Windows-1252 CSV from the computer and checks the warning for fields the table lacks.

Other formats

x2t reads more than the Office formats, and the file list opens those files too:

  • OpenDocument (.odt, .ods, .odp, as LibreOffice writes them) opens in the editor of its kind and is saved as what it is: x2t reads it into the binary format (odt → bin) and writes it back (bin → odt), as with a .docx (OpenOptions.openDocument, OPEN_DOCUMENT_FORMAT in src/converter.ts). "Herunterladen" gives the .odt; a spreadsheet can also be downloaded as CSV.
  • Older formats (Word, Excel and PowerPoint 97–2003, RTF) x2t reads but does not write; reading a .doc into the binary format stops with error 88, while doc → docx works. So Schreibzeug does what Word does with "Konvertieren": the page for such a file (apps/web/src/editor/convert-page.tsx) makes a copy in the Office format (OFFICE_FORMAT_OF_LEGACY: .doc and .rtf to .docx, .xls to .xlsx, .ppt to .pptx) beside it, under a free name ("Angebot.docx", "Angebot (2).docx"), and opens that; the file itself stays as it is. Someone the file is only shared with gets the copy in their own files.

e2e/other-formats.spec.ts edits and saves an .odt, an .ods and an .odp and checks the stored files (their mimetype part and their text), and opens a .doc, an .rtf, an .xls and a .ppt through their copy. LibreOffice reads the stored .odt back with its text. The test files are Schreibzeug's own, converted with LibreOffice (test-files/README.md).

Commands and state

The UI never calls the engine. editor-core translates engine events into EditorState (src/events.ts, src/state.ts) and offers commands (src/commands.ts):

Command Engine call Event back
bold, italic, underline put_TextPrBold/Italic/Underline(value) asc_onBold/Italic/Underline
font size put_TextPrFontSize(size) asc_onFontSize
alignment put_PrAlign(AscCommon.align_*) asc_onPrAlign
undo, redo Undo(), Redo() asc_onCanUndo, asc_onCanRedo
strikethrough put_TextPrStrikeout(value) asc_onStrikeout
font put_TextPrFontName(name) asc_onFontFamily; the list: asc_onInitEditorFonts
text colour put_TextColor(asc_CColor), automatic: Auto = true asc_onTextColor
paragraph style put_Style(name) asc_onParaStyleName; the list: asc_onInitEditorStyles
bulleted, numbered list put_ListTypeCustom(preset), end: { Type: "remove" } asc_onFocusObject, then asc_GetCurrentNumberingId / asc_GetNumberingPr
insert table put_Table(columns, rows) asc_onFocusObject with a Table object
rows, columns addRowAbove/Below(), addColumnLeft/Right(), remRow(), remColumn(), remTable() –
select all asc_EditSelectAll() –
superscript, subscript put_TextPrBaseline(AscCommon.vertalign_*) asc_onVerticalAlign
clear formatting ClearFormating() –
page break put_AddPageBreak() –
cut, copy, paste Cut(), Copy(), Paste(); false: the browser refused –
zoom zoomIn(), zoomOut(), zoom(percent), zoomFitToWidth(), zoomFitToPage() asc_onZoomChange(percent): document.zoom
mark saved SetUnchangedDocument() asc_onDocumentModifiedChanged
– – asc_onCountPages
– – asc_onStartAction / asc_onEndAction with c_oAscAsyncAction.Disconnect: document.connected

Builder scripts

OpenedDocument.runBuilder runs a script against the engine's builder API (word/apiBuilder.js, cell/apiBuilder.js, slide/apiBuilder.js) through callCommand (common/apiBase.js), as plugins do: one action of the history, recalculated once at the end, with the co-editing lock check. The MCP server's jobs for an open document use it (src/agent.ts, see the MCP server). The script is evaluated as it is, so it is wrapped in a function to return a value, and only plain data comes back (Asc.checkReturnCommand).

Comments

src/comments.ts reads and writes asc_CCommentDataWord (word/api.js) the way web-apps does (common/mobile/lib/controller/collaboration/Comments.jsx). A comment carries two times: Time, the local time written as if it were UTC, and OOTime, real milliseconds; Schreibzeug keeps one time in milliseconds and writes both.

Command Engine call Event back
add (on the selection) asc_addComment(data) asc_onAddComment(id, data)
reply, resolve asc_changeComment(id, data) with the whole comment and all replies asc_onChangeCommentData
delete asc_removeComment(id) asc_onRemoveComment
go to asc_selectComment(id) –
– asc_showComments(false) on content ready marks commented text asc_onShowComment(ids), asc_onHideComment

On open, the engine reports every comment of the file with asc_onAddComment; a comment carries the name of the account that wrote it.

The presentation engine speaks the same calls and events (slide/api.js, asc_CCommentDataSlide, exported as Asc.asc_CCommentData); a new comment belongs to the current slide, and asc_showComments() on content ready shows the comment marks on the slides.

The spreadsheet engine has the same comment fields (cell/model/CellComment.js asc_CCommentData) but puts a new comment on the whole workbook unless its document flag is false, so the editor sets it false and the comment goes on the active cell, whose reference the comment reports as its quote. asc_addComment returns nothing there; the id comes with asc_onAddComment. Besides the single events it reports lists: a sheet's comments on open and when it is shown (asc_onAddComments), moved ones (asc_onChangeComments) and removed ones (asc_onRemoveComments). A comment's id starts with sheet<id>_ for its sheet (asc_getWorksheetId), and the panel shows the comments of the sheet shown, as web-apps filters them. asc_showComments(false) marks commented cells.

All three editors share the commands (src/comments.ts), the state (src/comment-state.ts) and the panel (apps/web/src/editor/use-comments-panel.tsx).

asc_onDocumentModifiedChanged carries no value (word/api.js SetDocumentModified sends it bare); like web-apps (documenteditor/mobile Main.jsx), editor-core reads isDocumentModified() when it arrives.

Style names are the engine's English ones (Normal, Heading 1), as stored in the .docx; the toolbar shows the built-in ones in German (apps/web/src/toolbar/style-labels.ts). The list presets are the first bullet and number presets of web-apps (documenteditor/mobile store/textSettings.js), in src/lists.ts; the colour palette is Word's ten standard colours.

The engine takes target values, not toggles, so a toggle command reads the current state first.

The editor's title row carries a menu bar as in a desktop office suite (apps/web/src/ui/menu-bar.tsx). "Datei" and "Hilfe" are the same in every editor (src/editor/file-menu.ts); the editor adds its own menus between them (src/editor/word-menus.ts), built from the state and the commands above. The entries follow the desktop editors of web-apps (documenteditor/main Toolbar.js, DocumentHolder.js); the shortcuts shown are the engine's own (word/Editor/Shortcuts.js). On a phone the document gets the room: the header is one row (back, name, the save state as an icon, the editor's own buttons, "Werkzeuge" and "Speichern"), and the menus, the file's buttons and the toolbar show only after a tap on "Werkzeuge" (src/editor/editor-header.tsx, e2e/phone.spec.ts). Inside Kontext, Kontext leaves out its tab bar under an open file.

A text document opens on a phone in the engine's mobile view, as web-apps' mobile editor opens it (documenteditor/mobile Main.jsx, "mobile-view"): ChangeReaderMode lays the text out in the width of the screen at a size to read, still editable, without rulers (packages/editor-core/src/word-mobile-view.ts, apps/web/src/editor/use-mobile-view.ts). It is a layout only (word/Editor/Layout/ReadView.js, set without history), so the saved .docx stays as it is. The PDF and printing need the pages: for them the engine goes back to the pages, lays them out at once (RecalculateAllAtOnce, as asc_Print does) and returns to the mobile view. "Ansicht → Mobilansicht" switches it on any screen. A presentation on a phone has its slide list under the slide (thumbnails-position: bottom) and its notes hidden until "Ansicht → Notizen", as web-apps' mobile presentation editor has it (presentationeditor/mobile Main.jsx).

A right click in the document makes the engine send asc_onContextMenu with a CContextMenuData (word/api.js sync_ContextMenuCallback): the point relative to its view element and whether it was in a header or footer. editor-core adds the view element's place in the frame (src/view.ts toContextMenuRequest) and hands the request to onContextMenu listeners; the web app adds the frame's place in the window and shows its own menu there, as web-apps does (DocumentHolder.js showPopupMenu). Someone who may only look gets no context menu.

"Hilfe" has the keyboard shortcuts of the editor, the switch for the engine's screen reader support (accessibility) and "Über Schreibzeug" with the license and the link to the running source code, which the AGPL asks for (src/editor/help-dialogs.tsx).

Cut, copy and paste from a menu go through the engine (common/clipboard_base.js Button_Copy, Button_Cut, Button_Paste), which uses the async clipboard API; the frame allows it (allow="clipboard-read; clipboard-write"). When the browser refuses, the entry answers false and the app says which keys to use, like web-apps.

Page layout and spacing

The "Layout" menu sets the orientation, the paper size and the margins of the section at the cursor; "Format" and the toolbar set the line spacing and the indent of the paragraphs (src/layout.ts). The choices are web-apps' (documenteditor main view/Toolbar.js: A4, A5, Letter, Legal; margins normal, narrow, moderate, wide; spacing 1 to 3 lines), and so are the calls (controller/Toolbar.js): change_PageOrient(isPortrait), change_DocSize(width, height) with the upright size (the engine turns it for a landscape page), asc_SetSectionProps with an Asc.CDocumentSectionProps that has only the margins set (CDocument.Set_SectionProps leaves what is undefined alone), put_PrLineSpacing(Asc.linerule_Auto, multiple), and IncreaseIndent / DecreaseIndent. What the cursor's section and paragraph have comes from asc_onSectionProps (CDocument.UpdateInterface) and asc_onParaSpacingLine; a preset is checked when every value is within a tenth of a millimetre, as web-apps checks it.

Headers, footers and page numbers

"Einfügen" opens the header or footer of the current page (GoToHeader(page), GoToFooter(page) with getCurrentPage(), as web-apps' HeaderFooterTab.js) and, inside one, closes it (asc_CancelHdrFtrEditing) or removes it (asc_RemoveHeader, asc_RemoveFooter). Page numbers go in with put_PageNum(part, align), the numbers of web-apps' c_pageNumPosition (DocumentHolder.js: top 1, bottom 2; right 0, left 1, center 2), or put_PageNum(-1) at the cursor; asc_AddPageCount adds the number of pages (src/header-footer.ts). The menu is outside the frame, so going into the header or back out hands the keys to the engine again. Whether the cursor is in a header or footer comes from asc_onFocusObject (a Header element).

Table of contents, notes, breaks and text boxes

"Einfügen" puts in what web-apps' "Verweise" tab and toolbar offer (src/word-insert.ts, after documenteditor/mobile controller/add/AddTableContents.jsx, controller/add/AddOther.jsx, controller/add/AddShape.jsx):

  • Table of contents: asc_GetTableOfContentsPr() for the one the document has, or a new Asc.CTableOfContentsPr, set to headings 1 to 9 (put_OutlineRange(1, 9)), linked entries, page numbers right-aligned behind dots (c_oAscTabLeader.Dot), then asc_AddTableOfContents(null, props); with one there already that changes it instead of adding a second. "Inhaltsverzeichnis aktualisieren" is asc_UpdateTableOfContents(false) (entries and page numbers); without a table of contents the editor says so rather than letting the engine report ComplexFieldNoTOC.
  • Notes: asc_AddFootnote() and asc_AddEndnote() put the mark at the cursor and the cursor into the note. Strg+Alt+F is the engine's own shortcut (InsertFootnoteNow).
  • Breaks: put_AddPageBreak() (Strg+Enter), put_AddColumnBreak(), and add_SectionBreak(c_oAscSectionBreakType.NextPage | Continuous), which starts a section with its own page setup.
  • Text box: AddShapeOnCurrentPage("textRect") draws a rectangle that holds text in the middle of the page shown (GraphicObjects.addShapeOnPage), selected so the next keys go into it. It is saved as wps:txbx.

The menu is outside the frame, so after each of these the keys go back to the engine, as for the header and footer: the next thing typed lands in the note, the text box or the page.

"Link …" (Strg+K, the toolbar, the context menu) works as web-apps' link dialog (controller/Links.js, view/HyperlinkSettingsDialog.js): when can_AddHyperlink() answers the selected text, a new link goes in with add_Hyperlink; otherwise the cursor is in a link, whose Asc.CHyperlinkProperty from getSelectedElements() is changed or removed with change_Hyperlink and remove_Hyperlink (src/links.ts). An address without a scheme gets http:// (an e-mail address mailto:), and an unchanged text is passed as null so the linked text keeps its formatting. Strg+K reaches the app as asc_onDialogAddHyperlink; the engine's shortcut table is empty until it is reset to the defaults (asc_resetAllShortcutTypes, as web-apps' Shortcuts.js does), which the text editor does once the document shows. Strg+click on a link sends asc_onHyperlinkClick; the app opens http, https and mailto addresses in a new tab.

Highlight and format painter

"Hervorheben" highlights the selection in one of the first colours of web-apps' palette, or takes the highlight away: SetMarkerFormat(true, isColor, r, g, b) (CDocument.SetParagraphHighlight), then SetMarkerFormat(false), so the mouse does not stay a highlighter as in web-apps, where it does until Escape. The colour at the cursor is read from get_TextProps().get_TextPr().get_HighLight() when asc_onTextHighLight comes, as web-apps' onApiHighlightColor does, since the event itself reports "none" (-1) as black. "Format übertragen" is SetPaintFormat(1); the engine applies the copied formatting to the next selection made with the mouse and reports the painter off (asc_onPaintFormatChanged), but not on, so the button keeps that state itself (src/marking.ts).

Tracked changes

src/review.ts, after web-apps' review (common/main controller/ReviewChanges.js, common/mobile controller/collaboration/Review.jsx). The menu "Überprüfen" switches tracking, moves through the changes, accepts and rejects them and picks how they show; a balloon next to the change at the cursor names who made it and when, with accept and reject.

Command Engine call (word/api.js)
track changes asc_SetLocalTrackRevisions(null), asc_SetGlobalTrackRevisions(on): for the document, stored as w:trackRevisions in settings.xml, as web-apps' "for everyone"; state from asc_onOnTrackRevisionsChange(local, global) (local null: the document's setting holds)
accept, reject at the cursor asc_AcceptChangesBySelection(true), asc_RejectChangesBySelection(true): the change at the cursor or in the selection, then on to the next
previous, next asc_GetPrevRevisionsChange(), asc_GetNextRevisionsChange()
all asc_AcceptAllChanges(), asc_RejectAllChanges()
display asc_SetDisplayModeInReview(c_oAscDisplayModeInReview.Edit \| Final \| Original); there is no event, so the choice is kept by the editor

The changes at the cursor come with asc_onShowRevisionsChange(changes, isShow), each a CRevisionsChange (word/Editor/RevisionsChange.js): get_UserName, get_DateTime, get_Type (c_oAscRevisionsChangeType: text or paragraph added or removed, formatting), get_Value (the text, or a list of text and drawings) and get_X/get_Y relative to the view element (#editor_sdk). As web-apps, the balloon shows only for the cursor (isShow), not for a selection, and only while changes are marked. The author is the name the relay gives the session (see collaboration).

Spell checking

src/spelling.ts. Without a spell checker address the engine checks in a worker of its own (common/apiBase.js _coSpellCheckInit → CSpellchecker, common/spell/spell.js: Hunspell as WebAssembly) and loads <lang>/<lang>.aff and .dic from engine/dist/dictionaries, which engine/scripts/prepare.ts fills from the release package with the languages in engine.json (de_DE, de_AT, de_CH, en_US, en_GB) and their licenses.

  • Only shipped languages. The engine checks every language it lists (AscCommon.spellcheckGetLanguages); a language without its dictionary marks every word, which is why it was switched off before. The list is cut down to the dictionaries we ship before the editor starts (limitSpellLanguages); text in other languages stays unmarked.
  • A worker of the frame's own. From the editor's frame the shared worker (SharedWorker, named onlyoffice-spellchecker) never answered in Chromium, while a dedicated worker does; spell.js already does without the shared one for WebKit's WKWebView, so the frame hides SharedWorker before the editor starts (useDedicatedSpellWorker).
  • Started however fast it loads. The worker (common/spell/spell/spell.js) sets Module.onRuntimeInitialized only when the page's "init" message comes. Once the browser has the WebAssembly cached it is ready before that, the callback never comes and the build's assertions abort the module ("Attempt to set Module.onRuntimeInitialized after it has already been processed"): no spell checking in that document. engine/scripts/prepare.ts (patchSpellWorker) makes the worker call self.onEngineInit() at once when Module.calledRun is set; it stops with an error should an engine update change that line.
  • On or off with asc_setSpellCheck(on) once the document is ready, and from "Überprüfen → Rechtschreibprüfung"; the choice is kept in this browser (apps/web settings/spell-check.ts, as web-apps keeps "settings-spellcheck"). Someone who may only look gets no underlines.
  • Suggestions. With the cursor in a misspelled word, asc_onFocusObject lists a c_oAscTypeSelectElement.SpellCheck element (asc_CSpellCheckProperty: get_Word, get_Checked false, get_Variants). The context menu offers the first five variants (asc_replaceMisspelledWord(variant, element)), "Ignorieren" and "Alle ignorieren" (asc_ignoreMisspelledWord(element, all)), as web-apps' (documenteditor/main DocumentHolderExt.js spellProps). The same calls work in the presentation editor.

The format-fidelity pictures and the screenshots are taken with spell checking off, so a dictionary update does not change them.

Find and replace

src/search.ts fills a CSearchSettings (common/api/searchSettings.js: text, match case, whole words) in the engine's frame and calls asc_findText(settings, isNext), asc_replaceText(settings, replacement, isReplaceAll) and asc_endFindText(), as web-apps does (documenteditor/main controller/Search.js). The engine answers with asc_onSetSearchCurrent(current, total) and, after replacing all, asc_onReplaceAll(total, replaced); both go into state.search. When nothing matches it sends nothing, so the command reports that itself.

Strg+F and Strg+H are not handled by the engine (web-apps opens its own panel for them); a keydown listener in the frame (document.ts onKey with shortcutOf, handed out as onShortcut) and one in the app window open the search bar instead of the browser's search. Someone who may only look can search but not replace.

PDF and printing

This x2t build does not turn a document into a PDF by itself; it writes a PDF from the engine's drawing commands, as the Document Server does for printing. exportPdf (src/document.ts) hands the converter three things, the way CryptPad does (www/common/outer/x2t.js x2tConvertDataInternal):

  • the document in the engine's binary format (asc_nativeGetFile()), as input.bin;
  • every page as drawing commands, as pdf.bin next to it: WordControl.m_oDrawingDocument .ToRendererPart(true, true), what word/api.js _downloadAs sends for a PDF to print; x2t reads that file by this name (X2tConverter/src/lib/pdf_image.h doct_bin2pdf, CryptPad's patch);
  • the fonts the document was drawn with, in m_sFontDir: the font files the engine has loaded (AscCommon.g_font_loader, status 0 or 1), fetched again from its font folder (the browser has them cached) and with the obfuscation of their first 32 bytes undone, as the engine does when it loads them (common/Drawings/Externals.js, guidOdttf); src/print.ts.

"Als PDF/A herunterladen" asks x2t for the same PDF as PDF/A-2 (m_bIsPDFA in the task; X2tConverter/src/cextracttools.h → PdfFile.cpp CreatePdf(isPDFA)): XMP metadata with pdfaid:part 2, an sRGB output intent, every font embedded. For a PDF itself see PDFs.

"Drucken" prints that PDF through the browser's dialog from a hidden frame (apps/web/src/editor/pdf.ts). Strg+P is another key the engine leaves to its UI; it goes to the same place as Strg+F (onShortcut), and so does Strg+S, which saves through the app (the engine's own asc_Save would talk to the Document Server). The document's images go along as media/, as below.

Images

The converter keeps a document's images apart from its binary form: opening a .docx, x2t writes them into media/ next to the output, and saving or printing it reads them from there (engine/frame/x2t/worker.js; CryptPad does the same in www/common/outer/x2t.js). The engine itself never sees files; it looks every image up by its name in AscCommon.g_oDocumentUrls (common/editorscommon.js DocumentUrls), which the Document Server fills with download links. src/media.ts fills it with blob URLs made in the frame (provideMedia) after the converter answers, and on saving or printing fetches every media/ entry back into bytes (collectMedia), including images added while editing.

"Einfügen → Bild …" puts an image from the computer in at the cursor (insertImage): the app stores it on the server, which answers its name, and the image goes into DocumentUrls under that name with its server URL; then AddImageUrl([url]) (common/apiBase.js) finds the URL there and hands it to the editor's _addImageUrl (word/api.js, cell/api.js, slide/api.js), as after an upload to the Document Server (UploadImageFiles). Saving maps the URL back to the name (Shapes/SerializeWriter.js getImageLocal), so the image lands in the file's media/. How the others in a session get it is in collaboration.

Keyboard focus

The engine takes keys through a hidden text area in its frame (common/text_input2.js, id area_id) and cancels mousedown on its canvas. A click into the page therefore does not move focus out of the app: after a toolbar button had the focus, typing went nowhere. editor-core listens for pointerdown in the frame and, when nothing in the frame holds the keys, focuses the engine's input (api.getFocusElement(), common/apiBase.js); the engine's own focus handler then turns its key events back on (keepFocusOnClick in src/frame.ts). It checks again when the main button is released: a click into the presentation's notes leaves the focus on the page's body afterwards. A right click is left alone there, since the app's context menu then has the focus.

The way out again is F6: in the document Tab is the engine's (a tab, the next cell, the next form field), and Escape and Umschalt+Tab stay there too. shortcutOf (src/view.ts) takes F6 and Umschalt+F6 in the frame, before the engine, as it takes Strg+F; the web app moves to the menu bar, the toolbar or back into the document (focus(), the engine's input again; src/editor/regions.ts).

Screen reader

The engine's screen reader support (common/workerspeech.js) writes what the cursor passed and what got selected into div#area_id_screen_reader (role="region", aria-live="assertive") beside its input. src/screen-reader.ts switches it with setSpeechEnabled (common/apiBase.js), from the start when OpenOptions.screenReader says so; off, EditorActionSpeaker.stop has removed its callbacks. Strg+Alt+Z switches it in the engine itself (word/Editor/Shortcuts.js SpeechWorker, cell/view/EventsController.js, slide/Editor/Format/Presentation.js); a keyup listener reads EditorActionSpeaker.isLanched afterwards, so the store's screenReader and the menu follow. Its words come from the translate map of the API (OpenOptions.texts, which the app fills from its messages in apps/web/src/editor/engine-texts.ts; looked up by translateManager.getValue). Right after the content is ready the engine makes its input (word/api.js OpenDocument → InitControl); a microtask then names it after the file and replaces its autocomplete="extremely_off" (common/text_input2.js), which is no valid value.

Spreadsheets

src/cell.ts binds the spreadsheet engine (cell/api.js), following web-apps' spreadsheeteditor/mobile:

  • Formula bar. The engine types into an input it finds by id in its own document (config["id-input"], document.getElementById), so the formula bar is part of the frame page (engine/frame/editor/cell/main/index.html), with the name box beside it. The name box shows asc_onSelectionNameChanged and jumps with asc_findCell on Enter.
  • German regional format. asc_setLocale(0x0407) before loading: decimal comma, ; between function arguments. Function names stay English (SUM), as the engine has no German names without a translation file.
  • State. asc_onSelectionChanged brings the active cell (asc_CCellInfo → CellXfs: font, colours, alignment, wrap, number format; merge state), asc_onSheetsChanged and asc_onActiveSheetChanged the sheet tabs, read with asc_getWorksheetsCount, asc_getWorksheetName, asc_isWorksheetHidden, asc_getActiveWorksheetIndex.
  • The handler list. The engine keeps one function per event as is and only several as a list. While opening it unregisters a callback of its own for asc_onSelectionChanged, which deletes the whole event when only one function is there (cell/view/HandlerList.js remove). An empty first callback keeps ours. It also calls only what its asc_typeOf calls "function", so callbacks are never async functions.
Command Engine call (cell/api.js)
bold, italic, underline, strikethrough asc_setCellBold/Italic/Underline/Strikeout(value)
font, size asc_setCellFontName(name), asc_setCellFontSize(size) (at most 409)
text colour, fill asc_setCellTextColor(asc_CColor), asc_setCellBackgroundColor(asc_CColor \| null)
alignment, wrap asc_setCellAlign(AscCommon.align_*), asc_setCellTextWrap(value)
number format asc_setCellFormat(code): General, #,##0.00, #,##0.00\ [$€-407], 0.00%, dd.mm.yyyy, @
merge asc_mergeCells(c_oAscMergeOptions.Merge \| None)
rows, columns asc_insertCells(c_oAscInsertOptions.InsertRows \| InsertColumns), asc_deleteCells(…)
undo, redo asc_Undo(), asc_Redo(); state from asc_onCanUndoChanged, asc_onCanRedoChanged
sheets asc_showWorksheet(i), asc_addWorksheet(name), asc_renameWorksheet(name), asc_deleteWorksheet()
cut, copy, paste asc_Cut(), asc_Copy(), asc_Paste()
zoom asc_setZoom(factor), asc_getZoom(); asc_onZoomChanged(factor)
find, replace asc_findText(asc_CFindOptions) (the count), asc_replaceText(options); asc_onSetSearchCurrent, asc_onRenameCellTextEnd(found, replaced)
PDF wb.calcPagesPrint(), wb.printSheets(pages, null).DocumentRenderer.Memory.GetData(), as cell/api.js _downloadAs

The menus and the context menu work as in the text editor (apps/web/src/editor/sheet-menus.ts). The spreadsheet's right click hands over the mouse event itself (WorkbookView.js onContextMenu), whose point is already in the frame's coordinates. Search follows web-apps (spreadsheeteditor/main controller/Search.js): values in the active sheet, "whole words" as "whole cell"; matches are marked with asc_selectSearchingResults, because the spreadsheet's asc_endFindText fails in this build (it calls a _cleanFindResults that WorkbookView.js lacks).

The spreadsheet has no SetUnchangedDocument; its "modified" flag drops once the relay has the changes (asc_isDocumentModified, History.Have_Changes).

Sorting, filters and frozen panes

src/cell-data.ts, after web-apps' desktop editor (spreadsheeteditor/main controller/Toolbar.js onSortType, onAutoFilter, onClearFilter; controller/ViewTab.js onFreeze) and its mobile column filter (spreadsheeteditor/mobile controller/FilterOptions.jsx):

Command Engine call (cell/api.js)
sort asc_sortCellsRangeExpand(), then asc_sortColFilter(c_oAscSortOptions.Ascending \| Descending, "", undefined, undefined, expand)
filter on, off asc_addAutoFilter(); asc_changeAutoFilter(tableName, c_oAscChangeFilterOptions.filter, value) where buttons are or a formatted table is
clear filter asc_clearFilter()
freeze asc_freezePane() (at the active cell, or unfreeze), asc_freezePane(c_oAscFrozenPaneAddType.firstRow \| firstCol)
a column's filter asc_onSetAFDialog(asc_CAutoFilterObject): values with asc_getText/asc_getVisible; asc_setVisible on each, asc_getFilterObj().asc_setType(c_oAscAutoFilterTypes.Filters), asc_applyAutoFilter(object); sort by it with asc_sortColFilter(order, asc_getCellId(), asc_getDisplayName())
  • Sorting one column asks Excel's question in web-apps when more data is next to it (c_oAscSelectionSortExpand.showExpandMessage). Schreibzeug takes that question's default and sorts the rows along, the whole region around the selection; when someone else has those rows (showLockMessage) it sorts the selection alone, which is what "yes" there does.
  • The filter state comes with the selection: asc_getAutoFilterInfo() is true with buttons, false where they can be added and null where not; asc_getIsApplyAutoFilter() tells whether a filter hides rows. A formatted table's name is asc_getFormatTableInfo().asc_getTableName().
  • Frozen panes have no event: freezing waits for the pane's lock (WorksheetView.js _isLockedFrozenPane), the relay's answer in a shared session. asc_getSheetViewSettings() .asc_getIsFreezePane() is read again shortly after and whenever the selection moves; the menu reads "Fixierung aufheben" while something is frozen, as web-apps' does.
  • A column's filter opens under its button. The button's place is relative to the grid's canvas, inside #ws-canvas-outer; we add that element's place in the frame and the frame's in the window. "OK" applies the marked values; at least one stays shown.

Charts

src/cell-chart.ts, after web-apps' mobile spreadsheet editor (controller/add/AddChart.jsx onInsertChart, controller/edit/EditChart.jsx onType). "Einfügen" and the toolbar offer columns, bars, lines, a pie and areas (c_oAscChartTypeSettings barNormal, hBarNormal, lineNormal, pie, areaNormal):

  • A new chart: asc_getChartSettings(true) gives the settings for the selection (true without opening the chart editor's frame), putType(type), then asc_checkDataRange( c_oAscSelectionDialogType.Chart, getRange(), true, !getInColumns(), getType()); with c_oAscError.ID.No the chart goes in with asc_addChartDrawingObject(settings), too many series (MaxDataSeriesError) or no range are said in a message.
  • The selected chart: with asc_getSelectionType() RangeChart (or RangeChartText) the chart is among asc_getGraphicObjectProps(), its settings get_ObjectValue().get_ChartProperties(); changeType(type) changes it at once. The menu then checks the selected chart's kind.

src/cell-rules.ts, after web-apps' desktop spreadsheet editor (controller/DataTab.js onDataValidation with view/DataValidationDialog.js; controller/Toolbar.js for the conditional-format pickers):

  • A dropdown list ("Daten → Dropdown-Liste …") is a data validation of type list. asc_getDataValidationProps() gives the selection's rule; where the selection holds several rules it answers c_oAscError.ID.MoreOneTypeDataValidate and is asked again with null (start over), otherwise with true (extend the rule). The values go in as CDataFormula with the entries joined by ";" (the German argument separator), with asc_setType(List), asc_setAllowBlank(true), asc_setShowErrorMessage(true), then asc_setDataValidation(props); type None takes the list away. The .xlsx gets it in the x14 extension form.
  • Conditional formats ("Format"): a data bar and a green–yellow–red colour scale, the first preset of web-apps' pickers: asc_setCF([], [], [c_oAscCFRuleTypeSettings.dataBar | colorScale, 0]). asc_clearCF(c_oAscSelectionForCFType.selection) takes them off the selected cells.

Pivot tables

src/cell-pivot.ts, after web-apps' desktop spreadsheet editor (controller/PivotTable.js, view/PivotSettings.js, view/ValueFieldSettingsDialog.js); the engine keeps and computes the table (cell/model/PivotTables.js CT_pivotTableDefinition), and x2t writes it to the .xlsx with its cache (xl/pivotTables/, xl/pivotCache/).

  • A new one ("Einfügen → Pivot-Tabelle …"): the dialog starts with the table around the active cell (asc_getAddPivotTableOptions().asc_getRange()), checked with asc_checkDataRange( c_oAscSelectionDialogType.PivotTableData, …); asc_insertPivotNewWorksheet(source, "Pivot<n>") puts it on a new sheet with the first free name (createSheetName). It answers nothing where the range has no headings; the dialog says so.
  • The table at the active cell comes with the selection: asc_getCellInfo().asc_getPivotTableInfo(). Its fields are asc_getPivotFields() (or the source's asc_getCacheFields() names), and the four areas asc_getPageFields(), asc_getColumnFields(), asc_getRowFields() (field indexes; -2 is the "Werte" entry once there are two values) and asc_getDataFields() (CT_DataField with the source column, name and c_oAscDataConsolidateFunction). The field list beside the grid shows them while the active cell is in a pivot table.
  • Arranging: a ticked field goes in with asc_addField (numbers to the values, others to the rows, CT_CacheField.isSumSubtotal), an unticked one out with asc_removeField; asc_moveToRowField, asc_moveToColField, asc_moveToPageField, asc_moveToDataField move one (a value with its place among the values). Instead of dragging, each entry offers where it may go.
  • How a value is summed up: a CT_DataField with asc_setSubtotal and asc_setName, applied with value.asc_set(api, pivot, index, change). Values are named as German Excel names them ("Summe von Betrag", "Anzahl von …"): the engine fills its name pattern (%1 of %2) with the English function name, so the app renames each new value at once.
  • Refreshing after the source changed: asc_refresh(api) on the table, from "Daten", the context menu or the field list.
  • Captions in the interface's language: "Gesamtergebnis", "Zeilenbeschriftungen", "Spaltenbeschriftungen", "Werte", "(Alle)", "(Leer)" in German, through the translate map the API is created with (OpenOptions.texts, filled by apps/web/src/editor/engine-texts.ts; the keys are PivotTables.js's captions, as web-apps' Main.js passes them).
  • The engine changes the table without a selection change, so each command reads the state again.

CSV files

A .csv opens in the spreadsheet editor and is saved as a CSV again, with the delimiter it came with (src/csv.ts detectDelimiter: the one most of the first lines use outside quotes, a quoted field running on over a line break included, a quote inside a field such as 27" quoting nothing, ";" on a tie). The editor says once that only values are kept.

  • Reading: x2t reads a CSV into an .xlsx, not into the binary format (that stops with error 89), and only with both formats in the task (m_nFormatFrom 260, m_nFormatTo 257) plus m_nCsvTxtEncoding 46 (UTF-8) and m_nCsvDelimiter (c_oAscCsvDelimiter: tab 1, ";" 2, "," 4); m_nLcid 1031 makes it read "-4,20" and "01.10.2026" as a number and a date (core X2tConverter/src/cextracttools.h, OOXML/Binary/Sheets/Reader/CSVReader.cpp). The .xlsx then opens as usual.
  • Writing happens in the page, not in x2t: x2t's CSV writer encodes text through ICU, which has no converter data in the WebAssembly build, so every character past ASCII came out as a single byte (Writer/CSVWriter.cpp WriteFile, UnicodeConverter.cpp fromUnicode). The values are the active sheet's as its cells show them, through the Document Builder API (api.getJsApi().GetActiveSheet().GetUsedRange().GetText(), cell/apiBuilder.js), and encodeCsv writes them as UTF-8 with a byte order mark and CR LF, quoting what holds the delimiter, a quote or a line break. "Als CSV herunterladen" on an .xlsx does the same with ";".

Presentations

src/slide.ts binds the presentation engine (slide/api.js), set up as web-apps' presentationeditor/mobile does: SetDrawingFreeze(true) and SetThemesPath before loading, SetDrawingFreeze(false), zoomFitToPage() once the document is shown. The engine draws the slide list and the notes field inside its frame; the notes show once the document is shown (asc_ShowNotes(true), as web-apps' desktop editor does unless they were hidden), and "Ansicht → Notizen" hides and shows them. The engine does not report whether they show, so the command keeps that in the state. Its own texts (empty placeholders, "click to add notes") come from the translate map the API is created with (OpenOptions.texts): the app passes them in the interface's language (apps/web/src/editor/engine-texts.ts); without it the engine shows its own English (common/editorscommon.js CTranslateManager.getValue returns the key).

Character formatting is named and reported the same as in the text editor, so both share createCharacterCommands and textEvents (put_TextPrBold, asc_onBold, …).

Command Engine call (slide/api.js) Event back
new slide AddSlide(layoutIndex) asc_onCountPages, asc_onCurrentPage
layout ChangeLayout(layoutIndex); the layouts with their pictures: asc_onUpdateLayout (CLayoutThumbnail) –
duplicate, delete DublicateSlide() (the engine's spelling), DeleteSlide() asc_onCountPages
bullets, numbering put_ListType(0 \| 1, 1), end: put_ListType(…, -1) (CommonController.js fFillBullet) asc_onListType
character formatting, alignment, undo as in the text editor as in the text editor
clipboard, zoom, find and replace as in the text editor (Copy, zoom, asc_findText, …) as in the text editor
PDF WordControl.m_oDrawingDocument.ToRendererPart(true, false): all slides, as slide/api.js _downloadAs –

Shapes, designs and transitions

src/slide-design.ts, after web-apps (presentationeditor/mobile controller/add/AddShape.jsx, controller/edit/EditSlide.jsx, lib/editor.jsx; main controller/Transitions.js):

  • Shapes ("Einfügen"): AddShapeOnCurrentPage(preset) puts a shape in the middle of the slide, selected: a text box (textRect, saved with txBox="1"), a rectangle (rect), a rounded one (roundRect), an ellipse, an arrow (rightArrow) and a line. The keys go back to the engine afterwards, so typing fills the text box.
  • Designs ("Design"): the engine loads the list of its designs from themes.js in the themes path (SetThemesPath, g_defaultThemes) and sends it with asc_onInitEditorStyles ([editor's, document's], CAscThemeInfo with get_Index, get_Name); the menu names them in German. ChangeTheme(index) loads theme<index + 1>/theme.bin and applies it to every slide. The design in use is reported only by its master (asc_onUpdateThemeIndex with -master - 1), so none is checked.
  • Transitions ("Übergänge"): SetSlideProps(props) with a CAscSlideTransition of c_oAscSlideTransitionTypes and the first option of each, as web-apps' fillEffectTypes picks it (c_oAscSlideTransitionParams), on the selected slides; SlideTransitionApplyToAll() gives the current slide's to every slide. The selected slide's transition comes with asc_onFocusObject (the object of type Slide, get_transition().get_TransitionType()) and is checked.

The slide show is in "Ansicht" (from the start or from the current slide), beside the "Präsentieren" button.

A right click sends CContextMenuData as in the text editor; its type tells the slide (c_oAscContextMenuTypes.Main) from the slide list (Thumbnails), and the context menu offers the slides first in the list (apps/web/src/editor/slide-menus.ts).

New presentations come from packages/ooxml (pptx.ts): one master with the layouts "Titelfolie", "Titel und Inhalt", "Nur Titel" and "Leer", a theme with Office's default colours and Calibri, 16:9.

Slide show

"Präsentieren" (and "Ansicht → Bildschirmpräsentation …", from the start or from the current slide) shows the slides full screen, as web-apps' preview does (controller/Viewport.js onPreviewStart, mobile controller/Preview.jsx). The engine draws the show into an element of its own page, #presentation-preview (CDemonstrationManager.Start in slide/Drawing/Transitions.js), and handles the arrow keys, space, clicks and Escape there itself, but only while nothing in its page holds the keys (wrapKeyboard), so src/slide-show.ts gives the frame's window the focus and blurs the engine's input first. The app makes the frame full screen and starts the show once it has the screen's size (StartDemonstrationFromBeginning, StartDemonstrationFromCurrentSlide). Leaving full screen ends the show (EndDemonstration), and the end of the show (asc_onEndDemonstration) leaves full screen. Someone who may only look can present too.

PDFs

The PDF editor of the engine (pdf/api.js PDFEditorApi) is built into the text-document bundle, word/sdk-all-min.js; sdkjs/pdf/src/engine/ adds its renderer, drawingfile.wasm, which reads and draws the PDF itself. So engine.json lists pdf among the editors, and its host page (frame/editor/pdf/main/index.html) loads the word bundle and is checked for PDFEditorApi.

Unlike the Office editors, a PDF needs no conversion to open and keeps its format when it is saved:

  • Open. asc_openDocumentFromBytes(pdf) with the PDF's own bytes; the editor hands them to drawingfile.wasm (PDFEditorApi.openDocument → DocumentRenderer.open). The document info says pdf as its format; the rest of the load chain is the one in How a document opens.
  • Page thumbnails. When the host page has an element thumbnails-list, the editor fills it with the page thumbnails on content ready (onDocumentContentReady, pdf/src/thumbnails.js). Rotating, removing and moving pages act on the pages selected there, so the page has one.
  • Save. The editor's DocumentRenderer.Save() (pdf/src/viewer.js) returns a change file, not a PDF: "%PDF" and the pages, annotations, form values and page operations that changed. x2t writes it into the original: the worker puts the original beside the change file (changes/changes0.bin) and sets m_bFromChanges with the PDF format (513) both ways, and x2t applies it (X2tConverter/src/lib/pdf_image.h fromCrossPlatform → applyChangesPdf → applyCompiledChangesPdf). This is the Document Server's savefromorigin (word/api.js _downloadAs), done in the browser. createConverter().applyPdfChanges(original, changes, media) asks for it.

e2e/pdf-engine.spec.ts checks this contract below the app: it opens test-files/formular.pdf in the host page, rotates a page, removes one, fills a field, saves through the worker, opens the result again and reads the file back with pdf-lib.

The PDF view and its tools build on this; see PDFs.

Tests

packages/editor-core/test checks the binding against a stand-in for the engine (test/fake-engine.ts): what each command calls, and how engine events become state. The E2E specs run the real engine in Chromium:

Spec What it covers
e2e/word-roundtrip.spec.ts a .docx opened and saved back with its content, images, comments and formatting
e2e/word-formatting.spec.ts, e2e/word-menus.spec.ts the toolbar and the menus of the text editor: formatting, lists, layout, headers, notes, breaks
e2e/comments.spec.ts, e2e/review.spec.ts comments with answers, resolving and deleting; tracked changes
e2e/spelling.spec.ts the spell checker's marks and suggestions, by language
e2e/spreadsheet.spec.ts, e2e/pivot.spec.ts the spreadsheet editor, and pivot tables
e2e/presentation.spec.ts the presentation editor and the slide show
e2e/formats.fidelity.ts the format-fidelity pictures (architecture)