Headless Univer
Headless Univer는 대화형 편집기 UI가 없는 Univer 콘텐츠 런타임입니다. 브라우저 편집기와 콘텐츠 모델, 명령, 계산, Facade API를 공유합니다. Node.js에서 콘텐츠를 처리하거나 브라우저에서 UI 없는 작업을 실행할 수 있으며, 사용 가능한 기능은 등록한 제품·기능 플러그인에 따라 결정됩니다.
Node.js에서 문서를 생성·로드하고 문단, 텍스트, 구조, 스타일을 수정해 Univer 스냅샷을 저장할 수 있습니다. 표, 목록, 단, 수식, 도형은 해당 콘텐츠 플러그인이 필요합니다. 콘텐츠 수정만으로 페이지 스크린샷이 생성되지는 않습니다.
아래 예제는 플러그인 모드를 사용합니다. @univerjs/core와 제품 콘텐츠 플러그인은 Headless 계층에 속하지만 @univerjs/ui, 제품 *-ui, 기본 브라우저 코어 프리셋은 포함하지 않습니다. @univerjs/engine-render는 공유 의존성으로 등록하며 Canvas나 편집기를 마운트하지 않습니다. 패키지 경계에서 분류와 예외를 확인하세요.
로컬 스냅샷 작업에는 협업 서비스가 필요하지 않습니다. 협업 예제에는 클라이언트, 서버, 인증이 추가로 필요합니다. 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.KO_KR, locales: { [LocaleType.KO_KR]: {}, },})univer.registerPlugin(UniverRenderEnginePlugin)univer.registerPlugin(UniverFormulaEnginePlugin)univer.registerPlugin(UniverDocsPlugin)const univerAPI = FUniver.newAPI(univer)univerAPI.createDocument({})Node.js 환경의 공동 편집 예제
Node.js를 공동 편집 클라이언트로 사용하여 서버에 있는 기존 문서를 불러오고 Facade API로 내용을 수정한 뒤, 같은 문서를 연 다른 클라이언트에 변경 사항을 동기화할 수 있습니다. 실행 전에 공동 편집 서비스, 클라이언트 라이선스, 접근 가능한 문서 ID를 준비하세요.
예제 파일
파일을 다음 구조로 배치합니다.
src/docs/node/├── main.ts└── univer.tsuniver.ts: Univer를 생성하고 문서 기능 플러그인, Facade 확장 및 Node.js 공동 편집 클라이언트를 등록합니다.main.ts: 공동 편집 문서를 불러와 문단, 텍스트, 머리글과 바닥글, 콜아웃, 코드 블록, 인용 블록, 목록, 도형, 표 및 단 나누기 API를 차례로 실행합니다.
univer.ts의 your-license, 서버 주소, Cookie와 main.ts의 문서 ID를 바꾸세요.
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.KO_KR, 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('문서 도형 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 머리글 Facade 예제.', headerSegmentId) headerParagraph.appendText(` 문서: ${fDocument.getName() || fDocument.getId()}`) const footerParagraph = fDocument.appendParagraph('Docs Node.js 바닥글 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('바닥글 Facade 예제', footerSegmentId)?.getId())}function testDocsCalloutFacadeAPI(univerAPI: FUniver): void { const fDocument = univerAPI.getActiveDocument() if (!fDocument) return const paragraph = fDocument.appendParagraph('Docs Node.js 콜아웃 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 = "Docs Node.js 코드 블록 Facade에서 보내는 인사";') 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 인용 블록 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 목록 Facade 예제.') const success = fDocument.insertList(paragraph, { listType: univerAPI.Enum.PresetListType.BULLET_LIST, }) if (!success) return const item = fDocument.findListItemByText('목록 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, 'Facade API로 삽입한 단.') } 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()을 호출할 필요가 없습니다. 공동 편집 서비스는 customHeaders에 포함된 Cookie 등의 인증 정보로 사용자를 식별하고 권한을 확인하며, 연결의 memberID는 서버가 반환합니다.
이 예제는 브라우저 오프라인 캐시와 여러 탭 간 인스턴스 잠금을 비활성화하므로 계정별 캐시 격리를 위해 setCurrentUser()를 호출할 필요가 없습니다. 나중에 댓글처럼 로컬 현재 사용자에 의존하는 기능을 사용할 경우 UserManagerService로 업무 사용자를 설정할 수 있습니다. 이 설정은 서버 인증을 대체하지 않습니다.
머리글/바닥글 예제는 페이지 문서에만, 단 나누기 예제는 모던 문서에만 적용됩니다. 예제는 isModern()으로 문서 모드를 확인하고 지원하지 않는 작업을 건너뜁니다.
예제는 기존 문서에 내용을 삽입하므로 테스트 문서로 실행하는 것을 권장합니다. 예제는 실행 후에도 공동 편집 연결을 유지합니다. 브라우저에서 같은 문서를 열면 변경 사항을 확인할 수 있습니다. flush()는 대기 중인 공동 편집 변경 사항에 대한 서버 확인을 기다립니다. 일회성 스크립트로 변경한다면 모든 비동기 작업과 동기화가 완료된 뒤 univer.dispose()를 호출하여 인스턴스를 해제하세요.
이 가이드는 어떠셨나요?