Headless Univer
Headless Univer — среда выполнения Univer для работы с содержимым без интерактивного интерфейса редактора. Она использует общие с браузерным редактором модели, команды, вычисления и Facade API. Обработка возможна в Node.js и в браузере без UI. Доступные возможности определяются зарегистрированными плагинами продуктов и функций.
В Node.js можно создавать и загружать документы, менять абзацы, текст, структуру и стили, сохранять снимки Univer. Таблицы, списки, колонки, формулы и фигуры требуют своих плагинов содержимого. Изменение содержимого не создаёт изображение страницы.
Пример использует режим плагинов. @univerjs/core и плагины содержимого продукта входят в Headless; @univerjs/ui, пакеты продукта *-ui и стандартные браузерные пресеты сюда не входят. @univerjs/engine-render остаётся общей зависимостью без создания Canvas и монтирования редактора. См. границы пакетов Headless.
Локальные снимки не требуют сервиса совместной работы. Совместный пример дополнительно требует клиента, сервера и аутентификации. Для конвертации Office и снимков экрана нужны соответственно Server SDK и среда рендеринга. Импорт /facade сам по себе не регистрирует плагины и не превращает UI API в Headless API.
Установка
pnpm add @univerjs/core @univerjs/engine-render @univerjs/engine-formula @univerjs/docsnpm install @univerjs/core @univerjs/engine-render @univerjs/engine-formula @univerjs/docsyarn add @univerjs/core @univerjs/engine-render @univerjs/engine-formula @univerjs/docsbun add @univerjs/core @univerjs/engine-render @univerjs/engine-formula @univerjs/docsИспользование
import { LocaleType, Univer } from '@univerjs/core'import { FUniver } from '@univerjs/core/facade'import { UniverDocsPlugin } from '@univerjs/docs'import { UniverFormulaEnginePlugin } from '@univerjs/engine-formula'import { UniverRenderEnginePlugin } from '@univerjs/engine-render'import '@univerjs/docs/facade'const univer = new Univer({ locale: LocaleType.RU_RU, locales: { [LocaleType.RU_RU]: {}, },})univer.registerPlugin(UniverRenderEnginePlugin)univer.registerPlugin(UniverFormulaEnginePlugin)univer.registerPlugin(UniverDocsPlugin)const univerAPI = FUniver.newAPI(univer)univerAPI.createDocument({})Пример совместного редактирования в Node.js
Node.js может работать как клиент совместного редактирования: загружать существующий документ с сервера, изменять его содержимое через API Facade и синхронизировать изменения с другими клиентами, открывшими тот же документ. Перед запуском подготовьте сервис совместного редактирования, клиентскую лицензию и ID доступного документа.
Файлы примера
Расположите файлы следующим образом:
src/docs/node/├── main.ts└── univer.tsuniver.ts: создаёт Univer и регистрирует плагины функций документов, расширения Facade и клиент совместного редактирования Node.js.main.ts: загружает документ для совместного редактирования и демонстрирует API абзацев, текста, колонтитулов, блоков примечаний, блоков кода, цитат, списков, фигур, таблиц и колонок.
Замените your-license, адрес сервера и Cookie в univer.ts, а также ID документа в main.ts.
import { UniverCollaborationPlugin } from '@univerjs-pro/collaboration'import { UniverCollaborationClientPlugin } from '@univerjs-pro/collaboration-client'import { NodeCollaborationSocketService, UniverCollaborationClientNodePlugin,} from '@univerjs-pro/collaboration-client-node'import { UniverDocsCalloutPlugin } from '@univerjs-pro/docs-callout'import { UniverDocsCodePlugin } from '@univerjs-pro/docs-code'import { UniverDocsColumnPlugin } from '@univerjs-pro/docs-column'import { UniverDocsLatexPlugin } from '@univerjs-pro/docs-latex'import { UniverDocsListPlugin } from '@univerjs-pro/docs-list'import { UniverDocsQuotePlugin } from '@univerjs-pro/docs-quote'import { UniverDocsReferencePlugin } from '@univerjs-pro/docs-reference'import { UniverDocsShapePlugin } from '@univerjs-pro/docs-shape'import { UniverDocsTablePlugin } from '@univerjs-pro/docs-table'import { UniverLicensePlugin } from '@univerjs-pro/license'import { IAuthzIoService, IImageIoService, IMentionIOService, IUndoRedoService, LocaleType, Univer,} from '@univerjs/core'import { UniverDocsPlugin } from '@univerjs/docs'import { UniverDocsDrawingPlugin } from '@univerjs/docs-drawing'import { UniverDocsHyperLinkPlugin } from '@univerjs/docs-hyper-link'import { UniverDrawingPlugin } from '@univerjs/drawing'import { UniverRenderEnginePlugin } from '@univerjs/engine-render'import { UniverNetworkPlugin } from '@univerjs/network'import { UniverThreadCommentPlugin } from '@univerjs/thread-comment'import '@univerjs/docs/facade'import '@univerjs/docs-drawing/facade'import '@univerjs-pro/docs-shape/facade'import '@univerjs-pro/docs-callout/facade'import '@univerjs-pro/docs-reference/facade'import '@univerjs-pro/docs-code/facade'import '@univerjs-pro/docs-list/facade'import '@univerjs-pro/docs-quote/facade'import '@univerjs-pro/docs-table/facade'import '@univerjs-pro/docs-column/facade'import '@univerjs-pro/docs-latex/facade'import '@univerjs-pro/collaboration-client/facade'export function createUniverOnNode(): Univer { const univer = new Univer({ locale: LocaleType.RU_RU, locales: {}, override: [ [IAuthzIoService, null], [IUndoRedoService, null], [IMentionIOService, null], ], }) univer.registerPlugin(UniverLicensePlugin, { license: 'your-license', }) univer.registerPlugin(UniverRenderEnginePlugin) univer.registerPlugin(UniverThreadCommentPlugin) univer.registerPlugin(UniverDrawingPlugin, { override: [[IImageIoService, null]], }) univer.registerPlugin(UniverDocsPlugin) univer.registerPlugin(UniverDocsDrawingPlugin) univer.registerPlugin(UniverDocsHyperLinkPlugin) univer.registerPlugin(UniverDocsReferencePlugin) univer.registerPlugin(UniverDocsCalloutPlugin) univer.registerPlugin(UniverDocsCodePlugin) univer.registerPlugin(UniverDocsListPlugin) univer.registerPlugin(UniverDocsQuotePlugin) univer.registerPlugin(UniverDocsShapePlugin) univer.registerPlugin(UniverDocsTablePlugin) univer.registerPlugin(UniverDocsColumnPlugin) univer.registerPlugin(UniverDocsLatexPlugin) registerCollaborationPlugins(univer) return univer}function registerCollaborationPlugins(univer: Univer): void { const serverUrl = 'https://your-univer-server.example.com' univer.registerPlugin(UniverNetworkPlugin) univer.registerPlugin(UniverCollaborationPlugin) univer.registerPlugin(UniverCollaborationClientPlugin, { socketService: NodeCollaborationSocketService, enableOfflineEditing: false, enableSingleActiveInstanceLock: false, snapshotServerUrl: `${serverUrl}/universer-api/snapshot`, collabSubmitChangesetUrl: `${serverUrl}/universer-api/comb`, collabWebSocketUrl: `${serverUrl}/universer-api/comb/connect`, wsSessionTicketUrl: `${serverUrl}/universer-api/user/session-ticket`, uploadFileServerUrl: `${serverUrl}/universer-api/stream/file/upload`, signUrlServerUrl: `${serverUrl}/universer-api/file/{fileID}/sign-url`, downloadEndpointUrl: serverUrl, authzUrl: `${serverUrl}/universer-api/authz`, sendChangesetTimeout: 200, retryConnectingInterval: 1000, customHeaders: { // Пример: если сервер Univer использует аутентификацию через Cookie, задайте здесь заголовок 'cookie'. cookie: 'your-cookie', // ... другие заголовки }, }) univer.registerPlugin(UniverCollaborationClientNodePlugin)}import type { FConnectorShape } from '@univerjs-pro/engine-shape/facade'import { FUniver } from '@univerjs/core/facade'import process from 'node:process'import { createUniverOnNode } from './univer'async function run(): Promise<void> { const univer = createUniverOnNode() const univerAPI = FUniver.newAPI(univer) const document = await univerAPI.getCollaboration().loadDocAsync('your-unit-id') if (!document) { throw new Error( 'Не удалось загрузить документ для совместного редактирования. Проверьте ID документа и права доступа.', ) } testDocsFacadeAPI(univerAPI) testDocsHeaderFooterFacadeAPI(univerAPI) testDocsCalloutFacadeAPI(univerAPI) testDocsCodeFacadeAPI(univerAPI) testDocsQuoteFacadeAPI(univerAPI) testDocsListFacadeAPI(univerAPI) testDocsShapeFacadeAPI(univerAPI) testDocsTableFacadeAPI(univerAPI) testDocsColumnFacadeAPI(univerAPI) await univerAPI.getCollaboration().flush(document.getId()) console.warn('Изменения документа синхронизированы с сервером.')}function testDocsShapeFacadeAPI(univerAPI: FUniver) { const fDocument = univerAPI.getActiveDocument() if (!fDocument) return const paragraph = fDocument.getParagraphs()[0] if (!paragraph) return const anchor = { paragraphId: paragraph.getId(), segmentId: paragraph.getSegmentId(), } const shape = fDocument.insertShape({ shapeType: univerAPI.Enum.ShapeTypeEnum.RoundRect, placement: { wrappingStyle: univerAPI.Enum.TextWrappingStyle.WRAP_SQUARE, anchor, position: { horizontalOffset: 80, verticalOffset: 80 }, }, transform: { width: 320, height: 160 }, shapeData: { fill: { fillType: univerAPI.Enum.ShapeFillEnum.SolidFill, color: '#DBEAFE', }, stroke: { lineStrokeType: univerAPI.Enum.ShapeLineTypeEnum.SolidLine, color: '#2563EB', width: 2, }, }, }) if (!shape) return shape .setRotation(4) .setStrokeOpacity(0.9) .getText() .setText('Пример фигуры документа через API Facade') .setHorizontalAlign(univerAPI.Enum.HorizontalAlign.CENTER) .setVerticalAlign(univerAPI.Enum.VerticalAlign.MIDDLE) const target = fDocument.insertShape({ shapeType: univerAPI.Enum.ShapeTypeEnum.Ellipse, placement: { wrappingStyle: univerAPI.Enum.TextWrappingStyle.WRAP_SQUARE, anchor, position: { horizontalOffset: 500, verticalOffset: 100 }, }, transform: { width: 160, height: 120 }, shapeData: { fill: { fillType: univerAPI.Enum.ShapeFillEnum.SolidFill, color: '#DCFCE7' }, stroke: { lineStrokeType: univerAPI.Enum.ShapeLineTypeEnum.SolidLine, color: '#16A34A', width: 2 }, }, }) if (!target) return const connector = fDocument.insertShape({ shapeType: univerAPI.Enum.ShapeTypeEnum.BentConnector3, placement: { wrappingStyle: univerAPI.Enum.TextWrappingStyle.WRAP_SQUARE, anchor, position: { horizontalOffset: 360, verticalOffset: 130 }, }, transform: { width: 180, height: 80 }, }) if (!connector || !connector.isConnectorShape()) return const sourceSites = shape.getConnectionSites() const targetSites = target.getConnectionSites() ;(connector as FConnectorShape) .setStrokeColor('#475569') .setStrokeWidth(2) .setEndArrow(univerAPI.Enum.ShapeArrowTypeEnum.Arrow, univerAPI.Enum.ShapeArrowSizeEnum.Medium) .bindStart(shape.getId(), sourceSites[0]?.index ?? 0) .bindEnd(target.getId(), targetSites[2]?.index ?? targetSites[0]?.index ?? 0) console.warn('ID фигуры документа:', shape.getId()) console.warn('Количество фигур документа:', fDocument.getShapes().length)}function testDocsFacadeAPI(univerAPI: FUniver): void { const fDocument = univerAPI.getActiveDocument() if (!fDocument) return const intro = fDocument.appendParagraph( `Привет от клиента совместного редактирования Node.js. Текущее время: ${new Date().toString()}.`, ) intro.appendText(' [встроенная вставка]') const beforeIntro = fDocument.insertParagraph( intro.getInfo().paragraphIndex, 'Текст вставлен перед вводным абзацем с помощью объекта абзаца.', ) const foundIntro = fDocument.findParagraphByText('клиента совместного редактирования Node.js') const introMatches = fDocument.findParagraphs({ text: 'клиента совместного редактирования Node.js' }) const introRange = intro.getRange() const bodyBeforeDelete = fDocument.getBody() const deleteStartOffset = bodyBeforeDelete.dataStream.indexOf(' [встроенная вставка]') if (deleteStartOffset >= 0) { fDocument.deleteRange({ startOffset: deleteStartOffset, endOffset: deleteStartOffset + ' [встроенная вставка]'.length, }) } intro.setStyle({ textStyle: { fs: 12, }, }) console.warn(`ID текущего документа: ${fDocument.getId()}`) console.warn(`Название текущего документа: ${fDocument.getName()}`) console.warn(`Является ли текущий документ современным: ${fDocument.isModern()}`) console.warn(`ID вводного абзаца: ${intro.getId()}`) console.warn(`Индекс дочернего узла вводного абзаца после перемещения: ${intro.getInfo().paragraphIndex}`) console.warn('Диапазон вводного абзаца:', introRange) console.warn('ID найденного вводного абзаца:', foundIntro?.getId()) console.warn('Количество совпавших вводных абзацев:', introMatches.length) console.warn('Количество абзацев основного текста:', fDocument.getParagraphs().length) console.warn(`ID вставленного абзаца: ${beforeIntro.getId()}`) console.warn(`Длина основного текста снимка документа: ${fDocument.save().body?.dataStream.length ?? 0}`)}function testDocsHeaderFooterFacadeAPI(univerAPI: FUniver): void { const fDocument = univerAPI.getActiveDocument() if (!fDocument) return if (fDocument.isModern()) { console.warn('Пример колонтитулов пропущен: современные документы не поддерживают колонтитулы страниц.') return } const headerSegmentId = fDocument.ensurePageHeader() const footerSegmentId = fDocument.ensurePageFooter() const headerParagraph = fDocument.appendParagraph( 'Пример верхнего колонтитула Docs в Node.js через API Facade.', headerSegmentId, ) headerParagraph.appendText(` Документ: ${fDocument.getName() || fDocument.getId()}`) const footerParagraph = fDocument.appendParagraph( 'Пример нижнего колонтитула Docs в Node.js через API Facade.', footerSegmentId, ) fDocument.insertText(0, 'Верхний колонтитул страницы:', headerSegmentId) footerParagraph.setStyle({ textStyle: { fs: 10, }, }) console.warn('ID сегмента верхнего колонтитула документа:', headerSegmentId) console.warn( 'Абзацы верхнего колонтитула документа:', fDocument.getParagraphs(headerSegmentId).map((paragraph) => paragraph.getText()), ) console.warn('Длина текста верхнего колонтитула документа:', fDocument.getBody(headerSegmentId).dataStream.length) console.warn('ID сегмента нижнего колонтитула документа:', footerSegmentId) console.warn( 'ID абзаца нижнего колонтитула документа:', fDocument.findParagraphByText('нижнего колонтитула Docs в Node.js через API Facade', footerSegmentId)?.getId(), )}function testDocsCalloutFacadeAPI(univerAPI: FUniver): void { const fDocument = univerAPI.getActiveDocument() if (!fDocument) return const paragraph = fDocument.appendParagraph('Пример блока примечания Docs в Node.js через API Facade.') const callout = fDocument.insertCallout(paragraph, { blockId: createSampleId('callout'), config: { borderRadius: 8, icon: '!', }, }) if (!callout) return callout.updateConfig({ borderRadius: 8 }) console.warn('ID вставленного блока примечания:', callout.getId()) console.warn('Информация о блоке примечания документа:', callout.describe())}function testDocsCodeFacadeAPI(univerAPI: FUniver): void { const fDocument = univerAPI.getActiveDocument() if (!fDocument) return const paragraph = fDocument.appendParagraph('const message = "Привет от API Facade блоков кода Docs в Node.js";') const code = fDocument.insertCode(paragraph, { blockId: createSampleId('code'), config: { language: 'typescript', showLineNumbers: true, tabSize: 4, wrap: true, }, }) if (!code) return code.updateConfig({ config: { language: 'typescript', showLineNumbers: true, }, }) console.warn('ID вставленного блока кода:', code.getId()) console.warn('Информация о блоке кода документа:', code.describe())}function testDocsQuoteFacadeAPI(univerAPI: FUniver): void { const fDocument = univerAPI.getActiveDocument() if (!fDocument) return const paragraph = fDocument.appendParagraph('Пример блока цитаты Docs в Node.js через API Facade.') const quote = fDocument.insertQuote(paragraph, { blockId: createSampleId('quote'), }) if (!quote) return console.warn('ID вставленного блока цитаты:', quote.getId()) console.warn('Информация о блоке цитаты документа:', quote.describe())}function testDocsListFacadeAPI(univerAPI: FUniver): void { const fDocument = univerAPI.getActiveDocument() if (!fDocument) return const paragraph = fDocument.appendParagraph('Пример списка Docs в Node.js через API Facade.') const success = fDocument.insertList(paragraph, { listType: univerAPI.Enum.PresetListType.BULLET_LIST, }) if (!success) return const item = fDocument.findListItemByText('списка Docs в Node.js через API Facade') if (!item) return item.setGlyphSymbol('-', { mode: univerAPI.Enum.DocsListSelectionMode.Item }) item.setGlyphType(univerAPI.Enum.ListGlyphType.BULLET, { mode: univerAPI.Enum.DocsListSelectionMode.Item }) console.warn('Элемент списка документа:', item.describe()) console.warn('Элементы списка документа:', fDocument.describeListItems())}function testDocsTableFacadeAPI(univerAPI: FUniver): void { const fDocument = univerAPI.getActiveDocument() if (!fDocument) return const table = fDocument.insertTableFromData( [ ['Функция', 'Состояние', 'Ответственный'], ['Facade API', 'Готово', 'Клиент Docs в Node.js'], ['Совместное редактирование', 'Загружено', 'Клиент Node.js'], ], { headerRowCount: 1, tableId: createSampleId('table'), }, ) if (!table) return table.setCellText(1, 1, 'Обновлено') table.getCell(1, 2)?.setBackgroundColor('#E8F1FF') table.setColumnType(1, { type: univerAPI.Enum.DocsTableColumnType.Text }) table.setTableBorder({ color: '#3367D6', preset: univerAPI.Enum.DocsTableBorderPreset.All, width: 1, }) console.warn('Информация о таблице документа:', table.describe()) console.warn('ID таблицы, найденной по тексту:', fDocument.findTableByText('Facade API')?.getId())}function testDocsColumnFacadeAPI(univerAPI: FUniver): void { const fDocument = univerAPI.getActiveDocument() if (!fDocument) return if (!fDocument.isModern()) { console.warn('Пример колонок пропущен: группы колонок требуют современного документа.') return } const groupId = createSampleId('column-group') const group = fDocument.insertColumnGroup(3, { columnGroupId: groupId, columnIds: [createSampleId('column-left'), createSampleId('column-middle'), createSampleId('column-right')], gap: 18, widthRatios: [1.1, 1, 0.9], }) if (!group) return const [leftColumn, middleColumn, rightColumn] = group.getColumns() const leftOffset = leftColumn?.getInsertOffset() if (leftOffset != null) { fDocument.insertText(leftOffset, 'Левая колонка примера Facade.') } const middleOffset = middleColumn?.getInsertOffset() if (middleOffset != null) { fDocument.insertText(middleOffset, 'Средняя колонка примера Facade.') } const rightOffset = rightColumn?.getInsertOffset() if (rightOffset != null) { fDocument.insertTableFromData( [ ['Показатель', 'Сейчас', 'Следующий шаг'], ['Охват функций', 'Колонки', 'Верхний/Нижний колонтитул'], ], { headerRowCount: 1, offset: rightOffset, tableId: createSampleId('column-table'), }, ) } group.setWidthRatios([1.2, 1, 0.8]) const insertedColumn = middleColumn ? group.addColumn( middleColumn.getId(), univerAPI.Enum.DocsColumnPositionEnum.RIGHT, createSampleId('column-inserted'), ) : null const insertedOffset = insertedColumn?.getInsertOffset() if (insertedOffset != null) { fDocument.insertText(insertedOffset, 'Колонка вставлена через API Facade.') } const foundByText = fDocument.findColumnGroupByText('Средняя колонка') const foundById = fDocument.findColumnGroups({ columnGroupId: group.getId() }) const firstColumn = group.getColumn(0) const groupRange = group.getRange() const rangeLookup = groupRange ? fDocument.getColumnGroupAt(groupRange.startOffset) : null console.warn('Информация о группе колонок документа:', group.describe()) console.warn('Количество групп колонок, найденных по ID:', foundById.length) console.warn('ID группы колонок, найденной по тексту:', foundByText?.getId()) console.warn('ID группы колонок, найденной по диапазону:', rangeLookup?.getId()) console.warn('Текст первой колонки документа:', firstColumn?.getText())}function createSampleId(name: string): string { return `docs-node-${name}-${Date.now()}`}run().catch((error: unknown) => { console.error(error) process.exitCode = 1})Примечания по запуску
loadDocAsync() загружает существующий документ с сервера, поэтому вызывать createDocument() не требуется. Сервис совместного редактирования определяет пользователя и проверяет права по данным аутентификации, например Cookie в customHeaders. Сервер возвращает memberID соединения.
В примере отключены офлайн-кеш браузера и блокировка экземпляра между вкладками, поэтому вызывать setCurrentUser() для разделения кеша по учётным записям не нужно. Если позже понадобятся функции, зависящие от локального текущего пользователя, например комментарии, его можно задать через UserManagerService. Это не заменяет серверную аутентификацию.
Пример колонтитулов применим только к документам с разбивкой на страницы, а пример колонок — только к современным документам. Пример проверяет режим документа через isModern() и пропускает неподдерживаемые операции.
Пример вставляет содержимое в существующий документ, поэтому рекомендуется использовать тестовый документ. После выполнения пример сохраняет соединение совместного редактирования. Откройте тот же документ в браузере, чтобы увидеть изменения. flush() ожидает подтверждения сервера для изменений в очереди. Для одноразового скрипта вызовите univer.dispose() после завершения всех асинхронных операций и синхронизации.
Насколько полезно это руководство?