From 53dd8482fcd7bcb97099b963a7fd34962fd527a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD=E7=86=B1?= Date: Wed, 27 May 2026 16:00:24 +0800 Subject: [PATCH] fix(engine-render): add Thai language text rendering support (#6960) --- .../src/basics/__tests__/tools.spec.ts | 4 + packages/engine-render/src/basics/tools.ts | 6 + .../block/__tests__/block-error.spec.ts | 24 + .../layout/block/__tests__/section.spec.ts | 136 +++++ .../docs/layout/block/__tests__/table.spec.ts | 282 ++++----- .../paragraph/__tests__/bullet-ruler.spec.ts | 106 ++++ .../block/paragraph/__tests__/bullet.spec.ts | 242 +++++--- .../create-paragraph-layout-test-bed.ts | 209 +++++++ .../__tests__/language-ruler.spec.ts | 211 +++++++ .../paragraph/__tests__/layout-ruler.spec.ts | 323 +++-------- .../__tests__/line-adjustment.spec.ts | 246 +++----- .../paragraph/__tests__/linebreaking.spec.ts | 258 ++------- .../__tests__/paragraph-layout.spec.ts | 64 ++ .../block/paragraph/__tests__/shaping.spec.ts | 548 +++++++++--------- .../layout/block/paragraph/language-ruler.ts | 30 +- .../docs/layout/block/paragraph/shaping.ts | 17 +- 16 files changed, 1568 insertions(+), 1138 deletions(-) create mode 100644 packages/engine-render/src/components/docs/layout/block/__tests__/block-error.spec.ts create mode 100644 packages/engine-render/src/components/docs/layout/block/__tests__/section.spec.ts create mode 100644 packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/bullet-ruler.spec.ts create mode 100644 packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/create-paragraph-layout-test-bed.ts create mode 100644 packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/language-ruler.spec.ts create mode 100644 packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/paragraph-layout.spec.ts diff --git a/packages/engine-render/src/basics/__tests__/tools.spec.ts b/packages/engine-render/src/basics/__tests__/tools.spec.ts index 90efe2ef11..9a1a71d4bd 100644 --- a/packages/engine-render/src/basics/__tests__/tools.spec.ts +++ b/packages/engine-render/src/basics/__tests__/tools.spec.ts @@ -47,6 +47,7 @@ import { hasLatinExtendedB, hasLatinOneSupplement, hasSpace, + hasThai, hasTibetan, hasUnMergedCellInRow, injectStyle, @@ -264,6 +265,9 @@ describe('tools extra', () => { startColumn: 0, endColumn: 6, }); + + expect(hasThai('สร้างงานใหม่')).toBe(true); + expect(hasThai('hello')).toBe(false); }); it('handles style insertion and DOM size helpers', () => { diff --git a/packages/engine-render/src/basics/tools.ts b/packages/engine-render/src/basics/tools.ts index f3b17f59ab..791ff94ee5 100644 --- a/packages/engine-render/src/basics/tools.ts +++ b/packages/engine-render/src/basics/tools.ts @@ -434,6 +434,12 @@ export function hasTibetan(text: string) { return pattern.test(text); } +export function hasThai(text: string) { + const pattern = /[\u0E00-\u0E7F]/; + + return pattern.test(text); +} + export function hasSpace(text: string) { const pattern = /\s+/g; diff --git a/packages/engine-render/src/components/docs/layout/block/__tests__/block-error.spec.ts b/packages/engine-render/src/components/docs/layout/block/__tests__/block-error.spec.ts new file mode 100644 index 0000000000..72bc9b029e --- /dev/null +++ b/packages/engine-render/src/components/docs/layout/block/__tests__/block-error.spec.ts @@ -0,0 +1,24 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; +import { dealWithBlockError } from '../block-error'; + +describe('block-error', () => { + it('returns an empty array', () => { + expect(dealWithBlockError()).toEqual([]); + }); +}); diff --git a/packages/engine-render/src/components/docs/layout/block/__tests__/section.spec.ts b/packages/engine-render/src/components/docs/layout/block/__tests__/section.spec.ts new file mode 100644 index 0000000000..35deef61eb --- /dev/null +++ b/packages/engine-render/src/components/docs/layout/block/__tests__/section.spec.ts @@ -0,0 +1,136 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { IDocumentSkeletonPage } from '../../../../../basics/i-document-skeleton-cached'; +import { DataStreamTreeNodeType } from '@univerjs/core'; +import { describe, expect, it } from 'vitest'; +import { DataStreamTreeNode } from '../../../view-model/data-stream-tree-node'; +import { createSectionLayoutTestBed } from '../paragraph/__tests__/create-paragraph-layout-test-bed'; +import { dealWithSection } from '../section'; + +function getLastPage(pages: IDocumentSkeletonPage[]) { + return pages[pages.length - 1]; +} + +describe('section', () => { + describe('dealWithSection', () => { + it('processes a single paragraph', () => { + const { ctx, viewModel, sectionNode, curPage, sectionBreakConfig } = + createSectionLayoutTestBed(['Hello']); + + const result = dealWithSection(ctx, viewModel, sectionNode, curPage, sectionBreakConfig, null); + + expect(result.pages.length).toBeGreaterThanOrEqual(1); + expect(result.renderedBlockIdMap).toBeInstanceOf(Map); + const lastPage = getLastPage(result.pages); + expect(lastPage.sections.length).toBeGreaterThan(0); + }); + + it('processes multiple paragraphs', () => { + const { ctx, viewModel, sectionNode, curPage, sectionBreakConfig } = + createSectionLayoutTestBed(['Hello', 'World']); + + const result = dealWithSection(ctx, viewModel, sectionNode, curPage, sectionBreakConfig, null); + + expect(result.pages.length).toBeGreaterThanOrEqual(1); + const lastPage = getLastPage(result.pages); + const lines = lastPage.sections[0].columns[0].lines; + expect(lines.length).toBeGreaterThanOrEqual(2); + }); + + it('skips paragraphs before layoutAnchor', () => { + const { ctx, viewModel, sectionNode, curPage, sectionBreakConfig } = + createSectionLayoutTestBed(['Hello', 'World']); + + // layoutAnchor points to the end of the first paragraph. + const layoutAnchor = sectionNode.children[0].endIndex; + const result = dealWithSection( + ctx, + viewModel, + sectionNode, + curPage, + sectionBreakConfig, + layoutAnchor + ); + + expect(result.pages.length).toBeGreaterThanOrEqual(1); + const lastPage = getLastPage(result.pages); + const lines = lastPage.sections[0].columns[0].lines; + // Should only contain lines from the second paragraph onwards. + expect(lines.length).toBeGreaterThanOrEqual(1); + }); + + it('stops early when ctx.isDirty is true', () => { + const { ctx, viewModel, sectionNode, curPage, sectionBreakConfig } = + createSectionLayoutTestBed(['Hello', 'World']); + + ctx.isDirty = true; + const result = dealWithSection(ctx, viewModel, sectionNode, curPage, sectionBreakConfig, null); + + expect(result.pages.length).toBeGreaterThanOrEqual(1); + const lastPage = getLastPage(result.pages); + const lines = lastPage.sections[0].columns[0].lines; + // Only first paragraph is processed before dirty break. + expect(lines.length).toBeGreaterThanOrEqual(1); + }); + + it('rolls back pages when dirty and layoutStartPointer is set', () => { + const { ctx, viewModel, sectionNode, curPage, sectionBreakConfig } = + createSectionLayoutTestBed(['Hello']); + + // paragraphIndex in lines is set to the paragraphNode's endIndex. + const paragraphEndIndex = sectionNode.children[0].endIndex; + ctx.isDirty = true; + ctx.layoutStartPointer[''] = paragraphEndIndex; + + const result = dealWithSection(ctx, viewModel, sectionNode, curPage, sectionBreakConfig, null); + + expect(result.pages.length).toBeGreaterThanOrEqual(1); + const lastPage = getLastPage(result.pages); + const lines = lastPage.sections[0].columns[0].lines; + // After rollback, lines at or after the dirty paragraphIndex should be removed. + const rolledBackLines = lines.filter((line) => line.paragraphIndex >= paragraphEndIndex); + expect(rolledBackLines.length).toBe(0); + }); + + it('opens a new page when paragraphsOpenNewPage contains the paragraph endIndex', () => { + const { ctx, viewModel, sectionNode, curPage, sectionBreakConfig } = + createSectionLayoutTestBed(['Hello', 'World']); + + // Mark the second paragraph to open a new page. + ctx.paragraphsOpenNewPage.add(sectionNode.children[1].endIndex); + const result = dealWithSection(ctx, viewModel, sectionNode, curPage, sectionBreakConfig, null); + + // Should have two pages because the second paragraph starts on a new page. + expect(result.pages.length).toBeGreaterThanOrEqual(2); + }); + + it('handles an empty section', () => { + const { ctx, viewModel, curPage, sectionBreakConfig } = + createSectionLayoutTestBed(['Hello']); + + const emptySectionNode = new DataStreamTreeNode(DataStreamTreeNodeType.SECTION_BREAK, ''); + emptySectionNode.startIndex = 0; + emptySectionNode.endIndex = 0; + emptySectionNode.children = []; + + const result = dealWithSection(ctx, viewModel, emptySectionNode, curPage, sectionBreakConfig, null); + + expect(result.pages.length).toBe(0); + expect(result.renderedBlockIdMap.size).toBe(0); + }); + }); +}); diff --git a/packages/engine-render/src/components/docs/layout/block/__tests__/table.spec.ts b/packages/engine-render/src/components/docs/layout/block/__tests__/table.spec.ts index f998c693c0..2fb0a71dce 100644 --- a/packages/engine-render/src/components/docs/layout/block/__tests__/table.spec.ts +++ b/packages/engine-render/src/components/docs/layout/block/__tests__/table.spec.ts @@ -14,186 +14,138 @@ * limitations under the License. */ -import { BooleanNumber, TableAlignmentType, TableRowHeightRule, VerticalAlignmentType } from '@univerjs/core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ITable } from '@univerjs/core'; +import type { IParagraphList } from '../../../../../basics/i-document-skeleton-cached'; +import type { DataStreamTreeNode } from '../../../view-model/data-stream-tree-node'; +import { TableAlignmentType, TableSizeType } from '@univerjs/core'; +import { describe, expect, it } from 'vitest'; +import { getNullTableSkeleton, getTableIdAndSliceIndex, getTableSliceId, rollbackListCache } from '../table'; -import { - createTableSkeleton, - createTableSkeletons, - getNullTableSkeleton, - getTableIdAndSliceIndex, - getTableSliceId, - rollbackListCache, -} from '../table'; - -const createSkeletonCellPagesMock = vi.fn(); -const createNullCellPageMock = vi.fn(); - -vi.mock('../../model/page', () => ({ - createSkeletonCellPages: (...args: unknown[]) => createSkeletonCellPagesMock(...args), - createNullCellPage: (...args: unknown[]) => createNullCellPageMock(...args), -})); - -function createRowNode(startIndex: number, endIndex: number, cellCount: number) { +function createMockTable(overrides: Partial = {}): ITable { return { - startIndex, - endIndex, - children: new Array(cellCount).fill(0).map((_, i) => ({ - startIndex: startIndex + i * 5, - endIndex: startIndex + i * 5 + 4, - children: [], - })), - }; -} - -function createContextAndTable() { - const tableSource = { - tableId: 'table-1', + tableId: 'test-table', + tableRows: [], + tableColumns: [], align: TableAlignmentType.START, - indent: { v: 8 }, - tableRows: [ - { - repeatHeaderRow: BooleanNumber.TRUE, - trHeight: { - hRule: TableRowHeightRule.AUTO, - val: { v: 16 }, - }, - cantSplit: BooleanNumber.FALSE, - tableCells: [ - { vAlign: VerticalAlignmentType.TOP }, - { vAlign: VerticalAlignmentType.CENTER }, - ], - }, - { - repeatHeaderRow: BooleanNumber.FALSE, - trHeight: { - hRule: TableRowHeightRule.AT_LEAST, - val: { v: 28 }, - }, - cantSplit: BooleanNumber.TRUE, - tableCells: [ - { vAlign: VerticalAlignmentType.BOTTOM }, - { vAlign: VerticalAlignmentType.TOP }, - ], - }, - ], - } as any; - - const viewModel = { - getTableByStartIndex: vi.fn(() => ({ tableSource })), - } as any; - - const tableNode = { - startIndex: 0, - endIndex: 80, - children: [ - createRowNode(1, 20, 2), - createRowNode(21, 40, 2), - ], - } as any; - - const curPage = { - pageWidth: 400, - pageHeight: 300, - marginTop: 20, - marginBottom: 20, - marginLeft: 10, - marginRight: 10, - } as any; - - return { - ctx: {} as any, - sectionBreakConfig: {} as any, - tableSource, - viewModel, - tableNode, - curPage, - }; + indent: { v: 0 }, + textWrap: 0 as unknown as ITable['textWrap'], + position: {} as unknown as ITable['position'], + dist: {} as unknown as ITable['dist'], + size: { type: TableSizeType.UNSPECIFIED, width: { v: 100 } }, + ...overrides, + } as ITable; } -function makeCellPage(width: number, height: number) { - return { - width, - height, - pageWidth: width, - pageHeight: height, - marginTop: 1, - marginBottom: 1, - originMarginTop: 1, - originMarginBottom: 1, - sections: [], - }; -} - -describe('docs table layout', () => { - beforeEach(() => { - createSkeletonCellPagesMock.mockReset(); - createNullCellPageMock.mockReset(); - - createNullCellPageMock.mockImplementation(() => ({ - page: makeCellPage(60, 10), - })); - - createSkeletonCellPagesMock.mockImplementation( - (_ctx: unknown, _viewModel: unknown, _cellNode: unknown, _section: unknown, _table: unknown, row: number, col: number, availableHeight?: number) => { - const baseHeight = row === 0 ? 20 : 24; - if (row === 1 && col === 0 && typeof availableHeight === 'number' && availableHeight < 40) { - return [makeCellPage(60, baseHeight), makeCellPage(60, 16)]; - } - return [makeCellPage(60, baseHeight)]; - } - ); +describe('table utilities', () => { + describe('getTableSliceId', () => { + it('concatenates tableId and sliceIndex with delimiter', () => { + expect(getTableSliceId('table1', 0)).toBe('table1#-#0'); + expect(getTableSliceId('table1', 2)).toBe('table1#-#2'); + expect(getTableSliceId('my-table', 99)).toBe('my-table#-#99'); + }); }); - it('creates table skeleton and applies row/cell alignment data', () => { - const { ctx, curPage, viewModel, tableNode, sectionBreakConfig } = createContextAndTable(); + describe('getTableIdAndSliceIndex', () => { + it('parses sliced table id', () => { + expect(getTableIdAndSliceIndex('table1#-#0')).toEqual({ + tableId: 'table1', + sliceIndex: 0, + }); + expect(getTableIdAndSliceIndex('table1#-#2')).toEqual({ + tableId: 'table1', + sliceIndex: 2, + }); + }); - const skeleton = createTableSkeleton(ctx, curPage, viewModel, tableNode, sectionBreakConfig); - expect(skeleton).toBeTruthy(); - expect(skeleton?.rows.length).toBe(2); - expect(skeleton?.width).toBeGreaterThan(0); - expect(skeleton?.height).toBeGreaterThan(0); - expect(skeleton?.left).toBeGreaterThanOrEqual(0); - expect(skeleton?.rows[0].cells[0].marginTop).toBeGreaterThanOrEqual(1); - expect(skeleton?.rows[0].cells[1].marginTop).toBeGreaterThanOrEqual(1); + it('returns sliceIndex 0 for unsliced table id', () => { + expect(getTableIdAndSliceIndex('table1')).toEqual({ + tableId: 'table1', + sliceIndex: 0, + }); + }); }); - it('creates sliced tables when available height is limited', () => { - const { ctx, curPage, viewModel, tableNode, sectionBreakConfig } = createContextAndTable(); + describe('getNullTableSkeleton', () => { + it('returns a skeleton with zero dimensions and given bounds', () => { + const table = createMockTable(); + const skeleton = getNullTableSkeleton(10, 50, table); - const result = createTableSkeletons(ctx, curPage, viewModel, tableNode, sectionBreakConfig, 90); - expect(result.skeTables.length).toBeGreaterThan(0); - expect(typeof result.fromCurrentPage).toBe('boolean'); - expect(result.skeTables[0].rows.length).toBeGreaterThan(0); - - if (result.skeTables.length > 1) { - expect(result.skeTables[1].tableId).toContain('#-#'); - } + expect(skeleton.rows).toEqual([]); + expect(skeleton.width).toBe(0); + expect(skeleton.height).toBe(0); + expect(skeleton.top).toBe(0); + expect(skeleton.left).toBe(0); + expect(skeleton.st).toBe(10); + expect(skeleton.ed).toBe(50); + expect(skeleton.tableId).toBe('test-table'); + expect(skeleton.tableSource).toBe(table); + }); }); - it('handles rollback/slice id helpers and missing table branches', () => { - const listCache = new Map([ - ['a', [[{ paragraph: { startIndex: 1 } }, { paragraph: { startIndex: 20 } }]]], - ]); - rollbackListCache(listCache as any, { startIndex: 5, endIndex: 50 } as any); - expect(listCache.get('a')?.[0].length).toBe(1); + describe('rollbackListCache', () => { + it('removes paragraph lists whose startIndex is inside the table range', () => { + const paragraphList1: IParagraphList = { + bullet: {} as unknown as IParagraphList['bullet'], + paragraph: { startIndex: 5 } as unknown as IParagraphList['paragraph'], + }; + const paragraphList2: IParagraphList = { + bullet: {} as unknown as IParagraphList['bullet'], + paragraph: { startIndex: 15 } as unknown as IParagraphList['paragraph'], + }; + const paragraphList3: IParagraphList = { + bullet: {} as unknown as IParagraphList['bullet'], + paragraph: { startIndex: 25 } as unknown as IParagraphList['paragraph'], + }; - const sliceId = getTableSliceId('table-x', 3); - expect(sliceId).toBe('table-x#-#3'); - expect(getTableIdAndSliceIndex(sliceId)).toEqual({ tableId: 'table-x', sliceIndex: 3 }); - expect(getTableIdAndSliceIndex('table-y')).toEqual({ tableId: 'table-y', sliceIndex: 0 }); + const listLevel = new Map([ + ['list1', [[paragraphList1, paragraphList2, paragraphList3]]], + ]); - const nullTable = getNullTableSkeleton(1, 2, { tableId: 't0' } as any); - expect(nullTable.rows).toEqual([]); - expect(nullTable.tableId).toBe('t0'); + const tableNode = { + startIndex: 10, + endIndex: 20, + } as DataStreamTreeNode; - const { ctx, curPage, tableNode, sectionBreakConfig } = createContextAndTable(); - const noTableViewModel = { - getTableByStartIndex: vi.fn(() => null), - }; - expect(createTableSkeleton(ctx, curPage, noTableViewModel as any, tableNode, sectionBreakConfig)).toBeNull(); - const sliced = createTableSkeletons(ctx, curPage, noTableViewModel as any, tableNode, sectionBreakConfig, 100); - expect(sliced.skeTables).toEqual([]); - expect(sliced.fromCurrentPage).toBe(false); + rollbackListCache(listLevel, tableNode); + + const result = listLevel.get('list1')![0]; + expect(result).toHaveLength(1); + expect(result[0].paragraph.startIndex).toBe(5); + }); + + it('does not remove paragraph lists outside the table range', () => { + const paragraphList1: IParagraphList = { + bullet: {} as unknown as IParagraphList['bullet'], + paragraph: { startIndex: 1 } as unknown as IParagraphList['paragraph'], + }; + const paragraphList2: IParagraphList = { + bullet: {} as unknown as IParagraphList['bullet'], + paragraph: { startIndex: 2 } as unknown as IParagraphList['paragraph'], + }; + + const listLevel = new Map([ + ['list1', [[paragraphList1, paragraphList2]]], + ]); + + const tableNode = { + startIndex: 10, + endIndex: 20, + } as DataStreamTreeNode; + + rollbackListCache(listLevel, tableNode); + + const result = listLevel.get('list1')![0]; + expect(result).toHaveLength(2); + }); + + it('handles empty listLevel', () => { + const listLevel = new Map(); + const tableNode = { + startIndex: 10, + endIndex: 20, + } as DataStreamTreeNode; + + expect(() => rollbackListCache(listLevel, tableNode)).not.toThrow(); + }); }); }); diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/bullet-ruler.spec.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/bullet-ruler.spec.ts new file mode 100644 index 0000000000..98874cfc11 --- /dev/null +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/bullet-ruler.spec.ts @@ -0,0 +1,106 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ListGlyphType } from '@univerjs/core'; +import { describe, expect, it } from 'vitest'; +import { getBulletOrderedSymbol } from '../bullet-ruler'; + +describe('bullet-ruler', () => { + describe('getBulletOrderedSymbol with DECIMAL', () => { + it('returns "1" for startIndex=0, startNumber=1', () => { + expect(getBulletOrderedSymbol(0, 1, ListGlyphType.DECIMAL)).toBe('1'); + }); + + it('returns "10" for startIndex=9, startNumber=1', () => { + expect(getBulletOrderedSymbol(9, 1, ListGlyphType.DECIMAL)).toBe('10'); + }); + + it('returns "7" for startIndex=5, startNumber=2', () => { + expect(getBulletOrderedSymbol(5, 2, ListGlyphType.DECIMAL)).toBe('7'); + }); + }); + + describe('getBulletOrderedSymbol with DECIMAL_ZERO', () => { + it('prefixes single digit with zero', () => { + expect(getBulletOrderedSymbol(0, 1, ListGlyphType.DECIMAL_ZERO)).toBe('01'); + expect(getBulletOrderedSymbol(8, 1, ListGlyphType.DECIMAL_ZERO)).toBe('09'); + }); + + it('does not prefix double digits', () => { + expect(getBulletOrderedSymbol(9, 1, ListGlyphType.DECIMAL_ZERO)).toBe('10'); + expect(getBulletOrderedSymbol(10, 1, ListGlyphType.DECIMAL_ZERO)).toBe('11'); + }); + }); + + describe('getBulletOrderedSymbol with UPPER_LETTER', () => { + it('returns single uppercase letters', () => { + expect(getBulletOrderedSymbol(0, 1, ListGlyphType.UPPER_LETTER)).toBe('A'); + expect(getBulletOrderedSymbol(25, 1, ListGlyphType.UPPER_LETTER)).toBe('Z'); + }); + + it('returns multi-letter sequences', () => { + expect(getBulletOrderedSymbol(26, 1, ListGlyphType.UPPER_LETTER)).toBe('AA'); + expect(getBulletOrderedSymbol(51, 1, ListGlyphType.UPPER_LETTER)).toBe('ZZ'); + expect(getBulletOrderedSymbol(52, 1, ListGlyphType.UPPER_LETTER)).toBe('AAA'); + }); + }); + + describe('getBulletOrderedSymbol with LOWER_LETTER', () => { + it('returns single lowercase letters', () => { + expect(getBulletOrderedSymbol(0, 1, ListGlyphType.LOWER_LETTER)).toBe('a'); + expect(getBulletOrderedSymbol(25, 1, ListGlyphType.LOWER_LETTER)).toBe('z'); + }); + + it('returns multi-letter sequences', () => { + expect(getBulletOrderedSymbol(26, 1, ListGlyphType.LOWER_LETTER)).toBe('aa'); + expect(getBulletOrderedSymbol(51, 1, ListGlyphType.LOWER_LETTER)).toBe('zz'); + expect(getBulletOrderedSymbol(52, 1, ListGlyphType.LOWER_LETTER)).toBe('aaa'); + }); + }); + + describe('getBulletOrderedSymbol with UPPER_ROMAN', () => { + it('returns basic roman numerals', () => { + expect(getBulletOrderedSymbol(0, 1, ListGlyphType.UPPER_ROMAN)).toBe('I'); + expect(getBulletOrderedSymbol(3, 1, ListGlyphType.UPPER_ROMAN)).toBe('IV'); + expect(getBulletOrderedSymbol(8, 1, ListGlyphType.UPPER_ROMAN)).toBe('IX'); + }); + + it('returns complex roman numerals', () => { + expect(getBulletOrderedSymbol(9, 1, ListGlyphType.UPPER_ROMAN)).toBe('X'); + expect(getBulletOrderedSymbol(49, 1, ListGlyphType.UPPER_ROMAN)).toBe('L'); + expect(getBulletOrderedSymbol(99, 1, ListGlyphType.UPPER_ROMAN)).toBe('C'); + expect(getBulletOrderedSymbol(1993, 1, ListGlyphType.UPPER_ROMAN)).toBe('MCMXCIV'); + }); + }); + + describe('getBulletOrderedSymbol with LOWER_ROMAN', () => { + it('returns basic roman numerals', () => { + expect(getBulletOrderedSymbol(0, 1, ListGlyphType.LOWER_ROMAN)).toBe('i'); + expect(getBulletOrderedSymbol(3, 1, ListGlyphType.LOWER_ROMAN)).toBe('iv'); + expect(getBulletOrderedSymbol(8, 1, ListGlyphType.LOWER_ROMAN)).toBe('ix'); + }); + + it('returns complex roman numerals', () => { + expect(getBulletOrderedSymbol(1993, 1, ListGlyphType.LOWER_ROMAN)).toBe('mcmxciv'); + }); + }); + + describe('getBulletOrderedSymbol fallback', () => { + it('falls back to decimal for unknown glyphType', () => { + expect(getBulletOrderedSymbol(4, 1, 'unknown' as unknown as ListGlyphType)).toBe('5'); + }); + }); +}); diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/bullet.spec.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/bullet.spec.ts index 267478fb90..8ebf0eb898 100644 --- a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/bullet.spec.ts +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/bullet.spec.ts @@ -14,105 +14,167 @@ * limitations under the License. */ -import { ListGlyphType } from '@univerjs/core'; +import type { IBullet, ILists, INestingLevel } from '@univerjs/core'; +import { BulletAlignment, ListGlyphType } from '@univerjs/core'; import { describe, expect, it } from 'vitest'; import { dealWithBullet, getDefaultBulletSke } from '../bullet'; -describe('paragraph bullet', () => { - it('returns default bullet skeleton for missing list definitions', () => { - expect(dealWithBullet()).toBeUndefined(); +function createNestingLevel(overrides: Partial = {}): INestingLevel { + return { + bulletAlignment: BulletAlignment.START, + glyphFormat: '%1.', + startNumber: 1, + ...overrides, + }; +} - const fallbackNoList = dealWithBullet( - { listId: 'l1', listType: 'decimal', nestingLevel: 0 } as any, - {} as any, - [] - ); - expect(fallbackNoList).toEqual(expect.objectContaining({ - listId: 'l1', - symbol: '\u25CF', - })); +function createLists(nestingLevels: INestingLevel[]): ILists { + return { + 'test-list': { + listType: 'test-list', + nestingLevel: nestingLevels, + }, + }; +} - const fallbackNoNesting = dealWithBullet( - { listId: 'l2', listType: 'decimal', nestingLevel: 2 } as any, - { - decimal: { - nestingLevel: [{}], +function createBullet(overrides: Partial = {}): IBullet { + return { + listId: 'list-1', + listType: 'test-list', + nestingLevel: 0, + ...overrides, + }; +} + +describe('bullet', () => { + describe('dealWithBullet', () => { + it('returns undefined when bullet is missing', () => { + expect(dealWithBullet(undefined, {} as unknown as ILists)).toBeUndefined(); + }); + + it('returns undefined when lists is missing', () => { + expect(dealWithBullet(createBullet(), undefined as unknown as ILists)).toBeUndefined(); + }); + + it('falls back to default bullet when list is missing', () => { + const bullet = createBullet(); + const result = dealWithBullet(bullet, {} as unknown as ILists); + expect(result).toBeDefined(); + expect(result!.symbol).toBe('\u25CF'); + expect(result!.listId).toBe('list-1'); + }); + + it('falls back to default bullet when nestingLevel is missing', () => { + const bullet = createBullet(); + const lists = { + 'test-list': { + listType: 'test-list', }, - } as any, - [{ startIndexItem: 2 } as any, null, { startIndexItem: 8 } as any] - ); - expect(fallbackNoNesting?.startIndexItem).toBe(8); + }; + const result = dealWithBullet(bullet, lists as unknown as ILists); + expect(result).toBeDefined(); + expect(result?.symbol).toBe('\u25CF'); + }); - expect(getDefaultBulletSke('lx', 3).startIndexItem).toBe(3); + it('falls back to default bullet when nesting level index is out of bounds', () => { + const bullet = createBullet({ nestingLevel: 5 }); + const lists = createLists([createNestingLevel()]); + const result = dealWithBullet(bullet, lists as unknown as ILists); + expect(result).toBeDefined(); + expect(result?.symbol).toBe('\u25CF'); + }); + + it('generates ordered list symbol with glyphFormat', () => { + const bullet = createBullet(); + const lists = createLists([ + createNestingLevel({ glyphFormat: '%1.', glyphType: ListGlyphType.DECIMAL }), + ]); + const result = dealWithBullet(bullet, lists as unknown as ILists); + expect(result).toBeDefined(); + expect(result!.symbol).toBe('2.'); + expect(result!.bulletType).toBe(true); + }); + + it('uses glyphSymbol directly for unordered list', () => { + const bullet = createBullet(); + const lists = createLists([ + createNestingLevel({ glyphSymbol: '\u2022' }), + ]); + const result = dealWithBullet(bullet, lists as unknown as ILists); + expect(result).toBeDefined(); + expect(result!.symbol).toBe('\u2022'); + expect(result!.bulletType).toBe(false); + }); + + it('handles multi-level glyphFormat', () => { + const bullet = createBullet({ nestingLevel: 1 }); + const lists = createLists([ + createNestingLevel({ glyphFormat: '%1.', glyphType: ListGlyphType.DECIMAL }), + createNestingLevel({ glyphFormat: '%1.%2.', glyphType: ListGlyphType.DECIMAL }), + ]); + const listLevelAncestors: Array | null> = [ + { startIndexItem: 2, symbol: '2.' }, + null, + ]; + const result = dealWithBullet(bullet, lists as unknown as ILists, listLevelAncestors as unknown as Parameters[2]); + expect(result).toBeDefined(); + expect(result?.symbol).toBe('2.2.'); + }); + + it('merges textStyle from bullet and nestingLevel', () => { + const bullet = createBullet({ textStyle: { fs: 14 } }); + const lists = createLists([ + createNestingLevel({ textStyle: { ff: 'Arial' }, glyphType: ListGlyphType.DECIMAL }), + ]); + const result = dealWithBullet(bullet, lists as unknown as ILists); + expect(result).toBeDefined(); + expect(result!.ts.fs).toBe(14); + expect(result!.ts.ff).toBe('Arial'); + }); + + it('includes paragraphProperties from nestingLevel', () => { + const bullet = createBullet(); + const lists = createLists([ + createNestingLevel({ + glyphType: ListGlyphType.DECIMAL, + paragraphProperties: { + indentFirstLine: { v: 10 }, + }, + }), + ]); + const result = dealWithBullet(bullet, lists as unknown as ILists); + expect(result).toBeDefined(); + expect(result!.paragraphProperties).toBeDefined(); + }); + + it('inherits startIndexItem from listLevelAncestors', () => { + const bullet = createBullet(); + const lists = createLists([ + createNestingLevel({ glyphFormat: '%1.', glyphType: ListGlyphType.DECIMAL }), + ]); + const listLevelAncestors: Array | null> = [ + { startIndexItem: 5, symbol: '5.' }, + ]; + const result = dealWithBullet(bullet, lists as unknown as ILists, listLevelAncestors as unknown as Parameters[2]); + expect(result).toBeDefined(); + expect(result?.startIndexItem).toBe(6); + }); }); - it('builds ordered and unordered list symbols from nesting rules', () => { - const listLevelAncestors = [ - { startIndexItem: 4 } as any, - { startIndexItem: 3 } as any, - ]; - const lists = { - custom: { - nestingLevel: [ - { - startNumber: 1, - glyphType: ListGlyphType.DECIMAL, - glyphFormat: '%1.', - paragraphProperties: {}, - textStyle: { - ff: 'Times New Roman', - }, - }, - { - startNumber: 1, - glyphType: ListGlyphType.UPPER_LETTER, - glyphFormat: '%1.%2)', - paragraphProperties: {}, - textStyle: { - ff: 'Arial', - fs: 11, - }, - }, - ], - }, - unordered: { - nestingLevel: [ - { - glyphSymbol: '•', - glyphFormat: '', - paragraphProperties: {}, - textStyle: {}, - }, - ], - }, - } as any; + describe('getDefaultBulletSke', () => { + it('returns default bullet skeleton', () => { + const result = getDefaultBulletSke('list-default'); + expect(result.listId).toBe('list-default'); + expect(result.symbol).toBe('\u25CF'); + expect(result.ts.ff).toBe('Arial'); + expect(result.ts.fs).toBe(9); + expect(result.startIndexItem).toBe(1); + expect(result.paragraphProperties).toBeDefined(); + }); - const ordered = dealWithBullet( - { - listId: 'l-order', - listType: 'custom', - nestingLevel: 1, - textStyle: { - bl: 1, - }, - } as any, - lists, - listLevelAncestors - ); - expect(ordered?.symbol).toBe('4.D)'); - expect(ordered?.startIndexItem).toBe(4); - expect(ordered?.bulletType).toBe(true); - - const unordered = dealWithBullet( - { - listId: 'l-unorder', - listType: 'unordered', - nestingLevel: 0, - } as any, - lists, - listLevelAncestors - ); - expect(unordered?.symbol).toBe('•'); - expect(unordered?.bulletType).toBe(false); + it('uses custom startIndex', () => { + const result = getDefaultBulletSke('list-default', 10); + expect(result.startIndexItem).toBe(10); + }); }); }); diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/create-paragraph-layout-test-bed.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/create-paragraph-layout-test-bed.ts new file mode 100644 index 0000000000..30cdbc76af --- /dev/null +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/create-paragraph-layout-test-bed.ts @@ -0,0 +1,209 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { IDocumentData } from '@univerjs/core'; +import type { IDocumentSkeletonPage } from '../../../../../../basics/i-document-skeleton-cached'; +import type { ISectionBreakConfig } from '../../../../../../basics/interfaces'; +import type { DataStreamTreeNode } from '../../../../view-model/data-stream-tree-node'; +import type { ILayoutContext } from '../../../tools'; +import { DocumentDataModel, LocaleService, PRESET_LIST_TYPE, Univer } from '@univerjs/core'; +import { DocumentViewModel } from '../../../../view-model/document-view-model'; +import { Hyphen } from '../../../hyphenation/hyphen'; +import { LanguageDetector } from '../../../hyphenation/language-detector'; +import { createSkeletonPage } from '../../../model/page'; +import { getNullSkeleton, prepareSectionBreakConfig } from '../../../tools'; + +export interface IParagraphLayoutTestBed { + dataModel: DocumentDataModel; + viewModel: DocumentViewModel; + ctx: ILayoutContext; + paragraphNode: DataStreamTreeNode; + sectionBreakConfig: ISectionBreakConfig; + curPage: IDocumentSkeletonPage; +} + +function getLocaleService(): LocaleService { + const univer = new Univer(); + return univer.__getInjector().get(LocaleService); +} + +function createDocModel( + content: string, + overrides: { body?: Record; documentStyle?: Record; [key: string]: unknown } = {} +): DocumentDataModel { + const dataStream = `${content}\r\n`; + const bodyOverride = (overrides.body ?? {}) as Record; + const documentStyleOverride = (overrides.documentStyle ?? {}) as Record; + const documentData = { + id: 'test-doc', + drawings: {}, + drawingsOrder: [] as string[], + ...overrides, + body: { + dataStream, + textRuns: [{ st: 0, ed: dataStream.length, ts: {} }], + paragraphs: [{ startIndex: content.length }], + sectionBreaks: [{ startIndex: content.length + 1 }], + ...bodyOverride, + }, + documentStyle: { + pageSize: { width: 400, height: 600 }, + marginTop: 20, + marginBottom: 20, + marginLeft: 20, + marginRight: 20, + ...documentStyleOverride, + }, + }; + return new DocumentDataModel(documentData as unknown as Partial); +} + +function createLayoutContext(viewModel: DocumentViewModel): ILayoutContext { + const dataModel = viewModel.getDataModel(); + const { documentStyle, drawings, lists: customLists = {} } = dataModel; + const lists = { ...PRESET_LIST_TYPE, ...customLists }; + const { headerTreeMap, footerTreeMap } = viewModel.getHeaderFooterTreeMap(); + + const docsConfig = { + headerTreeMap, + footerTreeMap, + lists, + drawings, + localeService: getLocaleService(), + paragraphLineGapDefault: documentStyle.paragraphLineGapDefault ?? 0, + defaultTabStop: documentStyle.defaultTabStop ?? 10.5, + documentTextStyle: documentStyle.textStyle ?? {}, + }; + + const skeleton = getNullSkeleton(); + const { skeHeaders, skeFooters, skeListLevel, drawingAnchor } = skeleton; + + const skeletonResourceReference = { + skeHeaders, + skeFooters, + skeListLevel, + drawingAnchor, + }; + + return { + viewModel, + dataModel, + skeleton, + skeletonResourceReference, + docsConfig, + layoutStartPointer: { '': null }, + isDirty: false, + floatObjectsCache: new Map(), + paragraphConfigCache: new Map(), + sectionBreakConfigCache: new Map(), + paragraphsOpenNewPage: new Set(), + hyphen: Hyphen.getInstance(), + languageDetector: LanguageDetector.getInstance(), + } as ILayoutContext; +} + +function getFirstParagraphNode(viewModel: DocumentViewModel): DataStreamTreeNode { + const sectionNode = viewModel.getChildren()[0]; + return sectionNode.children[0]; +} + +function createInitialPage(ctx: ILayoutContext, sectionBreakConfig: ISectionBreakConfig): IDocumentSkeletonPage { + return createSkeletonPage(ctx, sectionBreakConfig, ctx.skeletonResourceReference); +} + +export interface ISectionLayoutTestBed { + dataModel: DocumentDataModel; + viewModel: DocumentViewModel; + ctx: ILayoutContext; + sectionNode: DataStreamTreeNode; + sectionBreakConfig: ISectionBreakConfig; + curPage: IDocumentSkeletonPage; +} + +export function createParagraphLayoutTestBed( + content: string, + overrides: { body?: Record; documentStyle?: Record; [key: string]: unknown } = {} +): IParagraphLayoutTestBed { + const dataModel = createDocModel(content, overrides); + const viewModel = new DocumentViewModel(dataModel); + const ctx = createLayoutContext(viewModel); + const paragraphNode = getFirstParagraphNode(viewModel); + const sectionBreakConfig = prepareSectionBreakConfig(ctx, 0); + const curPage = createInitialPage(ctx, sectionBreakConfig); + + return { + dataModel, + viewModel, + ctx, + paragraphNode, + sectionBreakConfig, + curPage, + }; +} + +export function createSectionLayoutTestBed( + contents: string[], + overrides: { body?: Record; documentStyle?: Record; [key: string]: unknown } = {} +): ISectionLayoutTestBed { + const dataStream = `${contents.join('\r')}\r\n`; + let currentIndex = 0; + const paragraphs = contents.map((content) => { + currentIndex += content.length; + const startIndex = currentIndex; + currentIndex += 1; // \r + return { startIndex }; + }); + + const bodyOverride = (overrides.body ?? {}) as Record; + const documentStyleOverride = (overrides.documentStyle ?? {}) as Record; + const documentData = { + id: 'test-doc', + drawings: {}, + drawingsOrder: [] as string[], + ...overrides, + body: { + dataStream, + textRuns: [{ st: 0, ed: dataStream.length, ts: {} }], + paragraphs, + sectionBreaks: [{ startIndex: dataStream.length - 1 }], + ...bodyOverride, + }, + documentStyle: { + pageSize: { width: 400, height: 600 }, + marginTop: 20, + marginBottom: 20, + marginLeft: 20, + marginRight: 20, + ...documentStyleOverride, + }, + }; + + const dataModel = new DocumentDataModel(documentData as unknown as Partial); + const viewModel = new DocumentViewModel(dataModel); + const ctx = createLayoutContext(viewModel); + const sectionNode = viewModel.getChildren()[0]; + const sectionBreakConfig = prepareSectionBreakConfig(ctx, 0); + const curPage = createInitialPage(ctx, sectionBreakConfig); + + return { + dataModel, + viewModel, + ctx, + sectionNode, + sectionBreakConfig, + curPage, + }; +} diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/language-ruler.spec.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/language-ruler.spec.ts new file mode 100644 index 0000000000..768615239a --- /dev/null +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/language-ruler.spec.ts @@ -0,0 +1,211 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ISectionBreakConfig } from '../../../../../../basics/interfaces'; +import { DocumentDataModel } from '@univerjs/core'; +import { describe, expect, it } from 'vitest'; +import { DocumentViewModel } from '../../../../view-model/document-view-model'; +import { ArabicHandler, emojiHandler, otherHandler, ThaiHandler, TibetanHandler } from '../language-ruler'; + +function createViewModel(content: string) { + const dataStream = `${content}\r\n`; + const documentData = { + id: 'test-doc', + body: { + dataStream, + textRuns: [{ st: 0, ed: dataStream.length, ts: {} }], + paragraphs: [{ startIndex: content.length }], + sectionBreaks: [{ startIndex: content.length + 1 }], + }, + documentStyle: {}, + drawings: {}, + drawingsOrder: [], + }; + const dataModel = new DocumentDataModel(documentData); + const viewModel = new DocumentViewModel(dataModel); + return { viewModel, dataModel }; +} + +function getParagraphNode(viewModel: DocumentViewModel) { + const sectionNode = viewModel.getChildren()[0]; + return sectionNode.children[0]; +} + +function getParagraph(viewModel: DocumentViewModel) { + const paragraphNode = getParagraphNode(viewModel); + return viewModel.getParagraph(paragraphNode.endIndex)!; +} + +function createSectionBreakConfig(): ISectionBreakConfig { + return { + gridType: 0, + charSpace: 0, + defaultTabStop: 10.5, + documentTextStyle: {}, + pageSize: { width: 500, height: 500 }, + marginLeft: 0, + marginRight: 0, + renderConfig: {}, + localeService: {} as unknown as NonNullable, + headerTreeMap: new Map(), + footerTreeMap: new Map(), + } as ISectionBreakConfig; +} + +describe('language-ruler', () => { + describe('otherHandler', () => { + it('processes English characters and stops at space', () => { + const { viewModel } = createViewModel('Hello world'); + const paragraphNode = getParagraphNode(viewModel); + const paragraph = getParagraph(viewModel); + const sectionBreakConfig = createSectionBreakConfig(); + + const result = otherHandler(0, 'Hello world', viewModel, paragraphNode, sectionBreakConfig, paragraph); + expect(result.step).toBe(5); + expect(result.glyphGroup.length).toBe(5); + expect(result.glyphGroup[0].content).toBe('H'); + }); + + it('processes numbers and punctuation', () => { + const { viewModel } = createViewModel('123-456'); + const paragraphNode = getParagraphNode(viewModel); + const paragraph = getParagraph(viewModel); + const sectionBreakConfig = createSectionBreakConfig(); + + const result = otherHandler(0, '123-456', viewModel, paragraphNode, sectionBreakConfig, paragraph); + expect(result.step).toBe(7); + expect(result.glyphGroup.length).toBe(7); + }); + + it('stops at emoji', () => { + const { viewModel } = createViewModel('Hi\uD83D\uDE00'); + const paragraphNode = getParagraphNode(viewModel); + const paragraph = getParagraph(viewModel); + const sectionBreakConfig = createSectionBreakConfig(); + + const result = otherHandler(0, 'Hi\uD83D\uDE00', viewModel, paragraphNode, sectionBreakConfig, paragraph); + expect(result.step).toBe(2); + expect(result.glyphGroup.length).toBe(2); + }); + }); + + describe('ArabicHandler', () => { + it('combines Arabic characters into one glyph in reverse order', () => { + const arabicText = '\u0645\u0631\u062D\u0628\u0627'; // 'مرحبا' + const { viewModel } = createViewModel(arabicText); + const paragraphNode = getParagraphNode(viewModel); + const paragraph = getParagraph(viewModel); + const sectionBreakConfig = createSectionBreakConfig(); + + const result = ArabicHandler(0, arabicText, viewModel, paragraphNode, sectionBreakConfig, paragraph); + expect(result.step).toBe(5); + expect(result.glyphGroup.length).toBe(1); + // Arabic characters are unshifted, so they should be in reverse order + expect(result.glyphGroup[0].content).toBe('\u0627\u0628\u062D\u0631\u0645'); // 'ابحرم' + }); + + it('stops at non-Arabic characters', () => { + const arabicText = '\u0645\u0631\u062D\u0628\u0627'; // 'مرحبا' + const { viewModel } = createViewModel(`${arabicText}X`); + const paragraphNode = getParagraphNode(viewModel); + const paragraph = getParagraph(viewModel); + const sectionBreakConfig = createSectionBreakConfig(); + + const result = ArabicHandler(0, `${arabicText}X`, viewModel, paragraphNode, sectionBreakConfig, paragraph); + expect(result.step).toBe(5); + expect(result.glyphGroup.length).toBe(1); + }); + }); + + describe('emojiHandler', () => { + it('extracts the first grapheme from emoji string', () => { + const emojiText = '\uD83D\uDE00\uD83D\uDC4B'; // '😀👋' + const { viewModel } = createViewModel(emojiText); + const paragraphNode = getParagraphNode(viewModel); + const paragraph = getParagraph(viewModel); + const sectionBreakConfig = createSectionBreakConfig(); + + const result = emojiHandler(0, emojiText, viewModel, paragraphNode, sectionBreakConfig, paragraph); + expect(result.glyphGroup.length).toBe(1); + expect(result.glyphGroup[0].content).toBe('\uD83D\uDE00'); // '😀' + }); + + it('handles single emoji', () => { + const { viewModel } = createViewModel('\uD83C\uDF89'); // '🎉' + const paragraphNode = getParagraphNode(viewModel); + const paragraph = getParagraph(viewModel); + const sectionBreakConfig = createSectionBreakConfig(); + + const result = emojiHandler(0, '\uD83C\uDF89', viewModel, paragraphNode, sectionBreakConfig, paragraph); + expect(result.glyphGroup.length).toBe(1); + expect(result.glyphGroup[0].content).toBe('\uD83C\uDF89'); + }); + }); + + describe('TibetanHandler', () => { + it('combines Tibetan characters into one word glyph', () => { + const tibetanText = '\u0180\u0190'; // Extended Latin characters (current hasTibetan range) + const { viewModel } = createViewModel(tibetanText); + const paragraphNode = getParagraphNode(viewModel); + const paragraph = getParagraph(viewModel); + const sectionBreakConfig = createSectionBreakConfig(); + + const result = TibetanHandler(0, tibetanText, viewModel, paragraphNode, sectionBreakConfig, paragraph); + expect(result.step).toBe(2); + expect(result.glyphGroup.length).toBe(1); + expect(result.glyphGroup[0].content).toBe(tibetanText); + }); + + it('stops at non-Tibetan characters', () => { + const tibetanText = '\u0180\u0190'; // Extended Latin characters + const { viewModel } = createViewModel(`${tibetanText}A`); + const paragraphNode = getParagraphNode(viewModel); + const paragraph = getParagraph(viewModel); + const sectionBreakConfig = createSectionBreakConfig(); + + const result = TibetanHandler(0, `${tibetanText}A`, viewModel, paragraphNode, sectionBreakConfig, paragraph); + expect(result.step).toBe(2); + expect(result.glyphGroup.length).toBe(1); + }); + }); + + describe('ThaiHandler', () => { + it('combines Thai characters into one word glyph', () => { + const thaiText = '\u0E2A\u0E27\u0E31\u0E2A\u0E14\u0E35'; // 'สวัสดี' + const { viewModel } = createViewModel(thaiText); + const paragraphNode = getParagraphNode(viewModel); + const paragraph = getParagraph(viewModel); + const sectionBreakConfig = createSectionBreakConfig(); + + const result = ThaiHandler(0, thaiText, viewModel, paragraphNode, sectionBreakConfig, paragraph); + expect(result.step).toBe(thaiText.length); + expect(result.glyphGroup.length).toBe(1); + expect(result.glyphGroup[0].content).toBe(thaiText); + }); + + it('stops at non-Thai characters', () => { + const thaiText = '\u0E2A\u0E27\u0E31\u0E2A\u0E14\u0E35'; // 'สวัสดี' + const { viewModel } = createViewModel(`${thaiText}X`); + const paragraphNode = getParagraphNode(viewModel); + const paragraph = getParagraph(viewModel); + const sectionBreakConfig = createSectionBreakConfig(); + + const result = ThaiHandler(0, `${thaiText}X`, viewModel, paragraphNode, sectionBreakConfig, paragraph); + expect(result.step).toBe(thaiText.length); + expect(result.glyphGroup.length).toBe(1); + }); + }); +}); diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/layout-ruler.spec.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/layout-ruler.spec.ts index 7a1f49263d..cb94c0079d 100644 --- a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/layout-ruler.spec.ts +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/layout-ruler.spec.ts @@ -14,260 +14,111 @@ * limitations under the License. */ -import { BooleanNumber, ColumnSeparatorType, DataStreamTreeTokenType, GridType, ObjectRelativeFromH, ObjectRelativeFromV, PositionedObjectLayoutType, SpacingRule } from '@univerjs/core'; +import type { IParagraphConfig } from '../../../../../../basics/interfaces'; import { describe, expect, it } from 'vitest'; -import { BreakPointType } from '../../../line-breaker/break'; -import { createSkeletonPage } from '../../../model/page'; -import { layoutParagraph, updateInlineDrawingPosition } from '../layout-ruler'; +import { layoutParagraph } from '../layout-ruler'; +import { lineBreaking } from '../linebreaking'; +import { shaping } from '../shaping'; +import { createParagraphLayoutTestBed } from './create-paragraph-layout-test-bed'; -function createGlyph(content: string, width = 16, streamType = 'text') { - return { - glyphType: 1, - streamType, - content, - raw: content, - count: content.length, - width, - left: 0, - xOffset: 0, - isJustifiable: false, - bBox: { - width, - ba: 10, - bd: 2, - aba: 10, - abd: 2, - sp: 0, - sbr: 0.6, - sbo: 2, - spr: 0.6, - spo: 3, - }, - adjustability: { - stretchability: [0, 0], - shrinkability: [0, 0], - }, - } as any; -} +describe('layout-ruler', () => { + it('lays out first shaped text with bullet skeleton', () => { + const { ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed('Item'); + const shapedTextList = shaping(ctx, paragraphNode.content!, ctx.viewModel, paragraphNode, sectionBreakConfig); + const bulletSkeleton = { + listId: 'list-1', + symbol: '\u25CF', + ts: { ff: 'Arial', fs: 9 }, + startIndexItem: 1, + paragraphProperties: { + indentFirstLine: { v: 0 }, + hanging: { v: 21 }, + indentStart: { v: 0 }, + }, + }; -function createLayoutContext() { - return { - viewModel: { - getSelfOrHeaderFooterViewModel: () => ({ - getDataModel: () => ({ - body: {}, - }), - }), - }, - dataModel: { - documentStyle: {}, - }, - docsConfig: {}, - skeleton: { - pages: [], - left: 0, - top: 0, - st: 0, - }, - layoutStartPointer: {}, - isDirty: false, - skeletonResourceReference: { - skeHeaders: new Map(), - skeFooters: new Map(), - skeListLevel: new Map(), - drawingAnchor: new Map(), - }, - floatObjectsCache: new Map(), - paragraphConfigCache: new Map(), - sectionBreakConfigCache: new Map(), - paragraphsOpenNewPage: new Set(), - hyphen: { - hasPattern: () => true, - loadPattern: () => Promise.resolve(), - }, - languageDetector: { - detect: () => 'en-us', - }, - } as any; -} - -function createSectionBreakConfig() { - return { - pageNumberStart: 1, - pageSize: { - width: 220, - height: 120, - }, - pageOrient: 0, - marginTop: 8, - marginBottom: 8, - marginLeft: 8, - marginRight: 8, - marginHeader: 0, - marginFooter: 0, - renderConfig: {}, - localeService: {} as any, - headerTreeMap: new Map(), - footerTreeMap: new Map(), - lists: {}, - drawings: {}, - columnProperties: [], - columnSeparatorType: ColumnSeparatorType.NONE, - gridType: GridType.DEFAULT, - linePitch: 18, - lineSpacing: 1.2, - spacingRule: SpacingRule.AUTO, - paragraphLineGapDefault: 1, - charSpace: 0, - defaultTabStop: 10.5, - autoHyphenation: BooleanNumber.TRUE, - consecutiveHyphenLimit: 0, - hyphenationZone: 5, - } as any; -} - -describe('layout ruler', () => { - it('lays out paragraph glyphs into lines/columns/pages with overflow and hyphen break handling', () => { - const ctx = createLayoutContext(); - const sectionBreakConfig = createSectionBreakConfig(); const paragraphConfig = { - paragraphIndex: 2, - paragraphStyle: { - snapToGrid: BooleanNumber.FALSE, - spaceAbove: { v: 4 }, - spaceBelow: { v: 3 }, - indentFirstLine: { v: 12 }, - hanging: { v: 6 }, - indentStart: { v: 4 }, - indentEnd: { v: 2 }, - }, - skeHeaders: new Map(), - skeFooters: new Map(), - pDrawingAnchor: new Map(), - } as any; + paragraphIndex: paragraphNode.endIndex, + paragraphStyle: {}, + bulletSkeleton, + } as unknown as IParagraphConfig; - const firstPage = createSkeletonPage( + const result = layoutParagraph( ctx, - sectionBreakConfig, - { - skeHeaders: paragraphConfig.skeHeaders, - skeFooters: paragraphConfig.skeFooters, - }, - 1 - ); - ctx.skeleton.pages = [firstPage]; - - const lineBreakerGroup = [ - createGlyph('longword', 120), - createGlyph('piece', 90), - createGlyph(DataStreamTreeTokenType.PARAGRAPH, 0, DataStreamTreeTokenType.PARAGRAPH), - ]; - - layoutParagraph( - ctx, - lineBreakerGroup, - ctx.skeleton.pages, + shapedTextList[0].glyphs, + [curPage], sectionBreakConfig, paragraphConfig, - true, - BreakPointType.Hyphen + true ); - for (let i = 0; i < 12; i++) { - layoutParagraph( + expect(result.length).toBe(1); + expect(result[0].sections.length).toBeGreaterThan(0); + }); + + it('lays out first shaped text without bullet', () => { + const { ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed('Hello world'); + const shapedTextList = shaping(ctx, paragraphNode.content!, ctx.viewModel, paragraphNode, sectionBreakConfig); + + const paragraphConfig = { + paragraphIndex: paragraphNode.endIndex, + paragraphStyle: {}, + } as unknown as IParagraphConfig; + + const result = layoutParagraph( + ctx, + shapedTextList[0].glyphs, + [curPage], + sectionBreakConfig, + paragraphConfig, + true + ); + + expect(result.length).toBe(1); + }); + + it('lays out non-first shaped text into existing page', () => { + const { ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed('Hello world this is a test'); + const shapedTextList = shaping(ctx, paragraphNode.content!, ctx.viewModel, paragraphNode, sectionBreakConfig); + + const paragraphConfig = { + paragraphIndex: paragraphNode.endIndex, + paragraphStyle: {}, + } as unknown as IParagraphConfig; + + // First layout + let result = layoutParagraph( + ctx, + shapedTextList[0].glyphs, + [curPage], + sectionBreakConfig, + paragraphConfig, + true + ); + + // Subsequent layout with isParagraphFirstShapedText=false + if (shapedTextList.length > 1) { + result = layoutParagraph( ctx, - [ - createGlyph(`S${i}`, 40), - createGlyph('tail', 30), - ], - ctx.skeleton.pages, + shapedTextList[1].glyphs, + result, sectionBreakConfig, - { - ...paragraphConfig, - paragraphIndex: i + 3, - }, - i === 0, - BreakPointType.Normal + paragraphConfig, + false ); } - const lastPage = ctx.skeleton.pages[ctx.skeleton.pages.length - 1]; - lastPage.sections[0].columns[0].isFull = true; - layoutParagraph( - ctx, - [createGlyph('newpage', 70)], - ctx.skeleton.pages, - sectionBreakConfig, - { - ...paragraphConfig, - paragraphIndex: 99, - }, - true, - BreakPointType.Normal - ); - - expect(ctx.skeleton.pages.length).toBeGreaterThan(1); - expect(ctx.skeleton.pages[0].sections[0].columns[0].lines.length).toBeGreaterThan(0); - expect(ctx.skeleton.pages.some((page: any) => page.sections.length > 0)).toBe(true); + expect(result.length).toBeGreaterThanOrEqual(1); }); - it('updates inline drawing positions and handles page-break detection fallback', () => { - const page = createSkeletonPage( - createLayoutContext(), - createSectionBreakConfig(), - { skeHeaders: new Map(), skeFooters: new Map() }, - 1 - ); - const section = page.sections[0]; - const column = section.columns[0]; + it('end-to-end: shapes and lays out text through lineBreaking', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed('Hello world'); + const shapedTextList = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); - const customGlyph = { - ...createGlyph('\u25A1', 20, 'custom'), - streamType: DataStreamTreeTokenType.CUSTOM_BLOCK, - drawingId: 'drawing-1', - } as any; - const divide = { - glyphGroup: [customGlyph], - left: 8, - paddingLeft: 4, - width: 120, - } as any; - const line = { - divides: [divide], - top: 24, - lineHeight: 18, - marginBottom: 2, - paragraphStart: false, - } as any; + const result = lineBreaking(ctx, viewModel, shapedTextList, curPage, paragraphNode, sectionBreakConfig, null); - divide.parent = line; - customGlyph.parent = divide; - line.parent = column; - column.lines = [line]; - - const inlineDrawings = new Map([ - ['drawing-1', { - drawingId: 'drawing-1', - drawingOrigin: { - layoutType: PositionedObjectLayoutType.INLINE, - docTransform: { - positionH: { relativeFrom: ObjectRelativeFromH.COLUMN }, - positionV: { relativeFrom: ObjectRelativeFromV.LINE }, - size: { width: 20, height: 12 }, - angle: 15, - }, - }, - }], - ]) as NonNullable[1]>; - - updateInlineDrawingPosition(line, inlineDrawings, 7); - const placed = page.skeDrawings.get('drawing-1'); - expect(placed).toBeTruthy(); - expect(placed?.aLeft).toBeGreaterThan(0); - expect(placed?.aTop).toBeGreaterThan(0); - expect(placed?.lineTop).toBe(24); - expect(placed?.blockAnchorTop).toBe(7); - - updateInlineDrawingPosition({ parent: null } as any, inlineDrawings, 0); + expect(result.length).toBeGreaterThanOrEqual(1); + const lastPage = result[result.length - 1]; + expect(lastPage.sections.length).toBeGreaterThan(0); }); }); diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/line-adjustment.spec.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/line-adjustment.spec.ts index b1a16c13e0..e654115d70 100644 --- a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/line-adjustment.spec.ts +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/line-adjustment.spec.ts @@ -15,181 +15,95 @@ */ import { HorizontalAlign } from '@univerjs/core'; -import { describe, expect, it, vi } from 'vitest'; -import { BreakPointType } from '../../../line-breaker/break'; - +import { describe, expect, it } from 'vitest'; import { lineAdjustment } from '../line-adjustment'; +import { lineBreaking } from '../linebreaking'; +import { shaping } from '../shaping'; +import { createParagraphLayoutTestBed } from './create-paragraph-layout-test-bed'; -const createHyphenDashGlyphMock = vi.fn(); -const glyphShrinkLeftMock = vi.fn(); -const glyphShrinkRightMock = vi.fn(); -const setGlyphGroupLeftMock = vi.fn(); -const getFontConfigFromLastGlyphMock = vi.fn(); +describe('line-adjustment', () => { + it('adjusts lines after layout', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed('Hello world'); + const shapedTextList = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); + const pages = lineBreaking(ctx, viewModel, shapedTextList, curPage, paragraphNode, sectionBreakConfig, null); -vi.mock('../../../model/glyph', () => ({ - createHyphenDashGlyph: (...args: unknown[]) => createHyphenDashGlyphMock(...args), - glyphShrinkLeft: (...args: unknown[]) => glyphShrinkLeftMock(...args), - glyphShrinkRight: (...args: unknown[]) => glyphShrinkRightMock(...args), - setGlyphGroupLeft: (...args: unknown[]) => setGlyphGroupLeftMock(...args), -})); - -vi.mock('../../../tools', () => ({ - getFontConfigFromLastGlyph: (...args: unknown[]) => getFontConfigFromLastGlyphMock(...args), - getGlyphGroupWidth: (divide: any) => divide.glyphGroup.reduce((sum: number, glyph: any) => sum + glyph.width, 0), - lineIterator: (pages: any[], cb: (line: any) => void) => { - pages.forEach((page) => { - page.sections.forEach((section: any) => { - section.columns.forEach((column: any) => { - column.lines.forEach((line: any) => cb(line)); - }); - }); - }); - }, -})); - -function createGlyph(content: string, width: number, isJustifiable = false) { - return { - content, - width, - xOffset: 0, - count: 1, - isJustifiable, - bBox: { - width: Math.max(1, width - 1), - ba: 7, - bd: 3, - }, - adjustability: { - shrinkability: [1, 1], - stretchability: [1, 1], - }, - } as any; -} - -function createPages() { - const divide1 = { - width: 40, - isFull: true, - breakType: BreakPointType.Normal, - paddingLeft: 0, - glyphGroup: [ - createGlyph('(', 8, false), - createGlyph('A', 10, true), - createGlyph('。', 8, false), - ], - } as any; - - const divide2 = { - width: 24, - isFull: true, - breakType: BreakPointType.Normal, - paddingLeft: 0, - glyphGroup: [ - createGlyph('中', 14, false), - ], - } as any; - divide2.glyphGroup[0].xOffset = 2; - divide2.glyphGroup[0].bBox.width = 8; - - const divide3 = { - width: 30, - isFull: true, - breakType: BreakPointType.Hyphen, - paddingLeft: 0, - glyphGroup: [ - createGlyph('w', 10, true), - createGlyph('o', 8, true), - createGlyph('r', 7, true), - createGlyph('d', 7, true), - ], - } as any; - divide3.glyphGroup[divide3.glyphGroup.length - 1].content = 'a'; - - const line = { - paragraphIndex: 0, - divides: [divide1, divide2, divide3], - } as any; - - [divide1, divide2, divide3].forEach((divide) => { - divide.glyphGroup.forEach((glyph: any, idx: number) => { - glyph.left = divide.glyphGroup.slice(0, idx).reduce((sum: number, g: any) => sum + g.width, 0); - glyph.parent = divide; - }); + // lineAdjustment should not throw + expect(() => lineAdjustment(pages, viewModel, paragraphNode, sectionBreakConfig)).not.toThrow(); }); - return [ - { - sections: [ - { - columns: [ - { - lines: [line], - }, - ], - }, - ], - }, - ] as any[]; -} + it('handles CJK punctuation shrinkage', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed('\u3002\u3002'); // Two full-width periods + const shapedTextList = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); + const pages = lineBreaking(ctx, viewModel, shapedTextList, curPage, paragraphNode, sectionBreakConfig, null); -describe('line adjustment', () => { - it('adjusts punctuation/hyphen/alignment for paragraph lines', () => { - createHyphenDashGlyphMock.mockReturnValue({ - content: '-', - width: 3, - count: 1, - left: 0, - bBox: { width: 3, ba: 7, bd: 3 }, - adjustability: { shrinkability: [0, 0], stretchability: [0, 0] }, - }); - getFontConfigFromLastGlyphMock.mockReturnValue({ fs: 12 }); - - const viewModel = { - getParagraph: () => ({ - startIndex: 0, - paragraphStyle: { - horizontalAlign: HorizontalAlign.JUSTIFIED, - }, - }), - } as any; - const paragraphNode = { endIndex: 1 } as any; - const pages = createPages(); - - lineAdjustment(pages as any, viewModel, paragraphNode, {} as any); - - const line = pages[0].sections[0].columns[0].lines[0]; - expect(glyphShrinkLeftMock).toHaveBeenCalled(); - expect(glyphShrinkRightMock).toHaveBeenCalled(); - expect(setGlyphGroupLeftMock).toHaveBeenCalled(); - expect(getFontConfigFromLastGlyphMock).toHaveBeenCalled(); - - const hyphenDivide = line.divides[2]; - expect(hyphenDivide.glyphGroup[hyphenDivide.glyphGroup.length - 1].content).toBe('-'); - expect(hyphenDivide.width).toBe(27); - - expect(line.divides[0].paddingLeft).toBeGreaterThanOrEqual(0); + expect(() => lineAdjustment(pages, viewModel, paragraphNode, sectionBreakConfig)).not.toThrow(); }); - it('supports center and right align branches', () => { - const pages = createPages(); - const paragraphNode = { endIndex: 1 } as any; + it('handles horizontal align CENTER', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed('Hello', { + body: { + dataStream: 'Hello\r\n', + textRuns: [{ st: 0, ed: 7, ts: {} }], + paragraphs: [{ + startIndex: 5, + paragraphStyle: { + horizontalAlign: HorizontalAlign.CENTER, + }, + }], + sectionBreaks: [{ startIndex: 6 }], + }, + }); + const shapedTextList = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); + const pages = lineBreaking(ctx, viewModel, shapedTextList, curPage, paragraphNode, sectionBreakConfig, null); - const viewModelCenter = { - getParagraph: () => ({ - startIndex: 0, - paragraphStyle: { horizontalAlign: HorizontalAlign.CENTER }, - }), - } as any; - lineAdjustment(pages as any, viewModelCenter, paragraphNode, {} as any); - expect(pages[0].sections[0].columns[0].lines[0].divides[0].paddingLeft).toBeGreaterThan(0); + expect(() => lineAdjustment(pages, viewModel, paragraphNode, sectionBreakConfig)).not.toThrow(); + }); - const viewModelRight = { - getParagraph: () => ({ - startIndex: 0, - paragraphStyle: { horizontalAlign: HorizontalAlign.RIGHT }, - }), - } as any; - lineAdjustment(pages as any, viewModelRight, paragraphNode, {} as any); - expect(pages[0].sections[0].columns[0].lines[0].divides[0].paddingLeft).toBeGreaterThan(0); + it('handles horizontal align RIGHT', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed('Hello', { + body: { + dataStream: 'Hello\r\n', + textRuns: [{ st: 0, ed: 7, ts: {} }], + paragraphs: [{ + startIndex: 5, + paragraphStyle: { + horizontalAlign: HorizontalAlign.RIGHT, + }, + }], + sectionBreaks: [{ startIndex: 6 }], + }, + }); + const shapedTextList = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); + const pages = lineBreaking(ctx, viewModel, shapedTextList, curPage, paragraphNode, sectionBreakConfig, null); + + expect(() => lineAdjustment(pages, viewModel, paragraphNode, sectionBreakConfig)).not.toThrow(); + }); + + it('handles horizontal align JUSTIFIED', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed('Hello world test', { + body: { + dataStream: 'Hello world test\r\n', + textRuns: [{ st: 0, ed: 18, ts: {} }], + paragraphs: [{ + startIndex: 16, + paragraphStyle: { + horizontalAlign: HorizontalAlign.JUSTIFIED, + }, + }], + sectionBreaks: [{ startIndex: 17 }], + }, + }); + const shapedTextList = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); + const pages = lineBreaking(ctx, viewModel, shapedTextList, curPage, paragraphNode, sectionBreakConfig, null); + + expect(() => lineAdjustment(pages, viewModel, paragraphNode, sectionBreakConfig)).not.toThrow(); + }); + + it('handles line with only paragraph break', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed(''); + const shapedTextList = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); + const pages = lineBreaking(ctx, viewModel, shapedTextList, curPage, paragraphNode, sectionBreakConfig, null); + + expect(() => lineAdjustment(pages, viewModel, paragraphNode, sectionBreakConfig)).not.toThrow(); }); }); diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/linebreaking.spec.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/linebreaking.spec.ts index c8c48ec162..5151a4ba4b 100644 --- a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/linebreaking.spec.ts +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/linebreaking.spec.ts @@ -14,222 +14,70 @@ * limitations under the License. */ -import { DataStreamTreeTokenType, PositionedObjectLayoutType } from '@univerjs/core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { BreakType } from '../../../../../../basics/i-document-skeleton-cached'; - +import { describe, expect, it } from 'vitest'; import { lineBreaking } from '../linebreaking'; - -const createSkeletonPageMock = vi.fn((ctx: any, _config: any, _ref: any, pageNumber: number, breakType?: BreakType): any => ({ - segmentId: 'segment-1', - pageNumber, - breakType, -})); - -const setColumnFullStateMock = vi.fn((..._args: any[]): any => undefined); -const getLastNotFullColumnInfoMock = vi.fn((..._args: any[]): any => undefined); -const dealWithBulletMock = vi.fn((..._args: any[]): any => ({ id: 'bullet-skeleton' })); -const layoutParagraphMock = vi.fn((...args: any[]): any => { - const glyphs = args[1] as any[]; - const pages = args[2] as any[]; - return glyphs.length > 0 ? [...pages] : pages; -}); - -vi.mock('../../../model/page', () => ({ - createSkeletonPage: ( - ctx: any, - config: any, - ref: any, - pageNumber: number, - breakType?: BreakType - ) => createSkeletonPageMock(ctx, config, ref, pageNumber, breakType), -})); - -vi.mock('../../../model/section', () => ({ - setColumnFullState: (column: any, full: boolean) => setColumnFullStateMock(column, full), -})); - -vi.mock('../../../tools', () => ({ - getLastNotFullColumnInfo: (page: any) => getLastNotFullColumnInfoMock(page), -})); - -vi.mock('../bullet', () => ({ - dealWithBullet: (bullet: any, lists: any, listLevelAncestors: any, localeService: any) => dealWithBulletMock(bullet, lists, listLevelAncestors, localeService), -})); - -vi.mock('../layout-ruler', () => ({ - layoutParagraph: ( - ctx: any, - glyphs: any[], - pages: any[], - sectionBreakConfig: any, - paragraphConfig: any, - isParagraphFirstShapedText: boolean, - breakPointType: any - ) => layoutParagraphMock(ctx, glyphs, pages, sectionBreakConfig, paragraphConfig, isParagraphFirstShapedText, breakPointType), -})); - -function createContext() { - return { - paragraphConfigCache: new Map(), - skeletonResourceReference: { - skeHeaders: new Map(), - skeFooters: new Map(), - skeListLevel: new Map(), - drawingAnchor: new Map(), - }, - } as any; -} - -function createViewModel() { - return { - getParagraph: vi.fn(() => ({ - startIndex: 0, - paragraphStyle: {}, - bullet: { listId: 'l1', nestingLevel: 0 }, - })), - getCustomBlock: vi.fn((charIndex: number) => { - if (charIndex === 1) { - return { blockId: 'inline-1' }; - } - if (charIndex === 2) { - return { blockId: 'float-1' }; - } - return null; - }), - } as any; -} +import { shaping } from '../shaping'; +import { createParagraphLayoutTestBed } from './create-paragraph-layout-test-bed'; describe('linebreaking', () => { - beforeEach(() => { - vi.clearAllMocks(); + it('lays out short text on a single page', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed('Hi'); + const shapedTextList = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); + + const result = lineBreaking(ctx, viewModel, shapedTextList, curPage, paragraphNode, sectionBreakConfig, null); + + expect(result.length).toBe(1); + expect(result[0].sections.length).toBeGreaterThan(0); }); - it('handles page-break and non-last column-break branches', () => { - const ctx = createContext(); - const viewModel = createViewModel(); - getLastNotFullColumnInfoMock.mockReturnValue({ - column: { id: 'c1' }, - isLast: false, - }); + it('lays out longer text that may span multiple lines', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed('This is a longer text that should still fit within a reasonable page width for testing purposes'); + const shapedTextList = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); - const curPage = { - segmentId: 'segment-1', - pageNumber: 1, - } as any; + const result = lineBreaking(ctx, viewModel, shapedTextList, curPage, paragraphNode, sectionBreakConfig, null); - const paragraphNode = { - endIndex: 10, - startIndex: 0, - blocks: [1, 2], - children: [{}], - } as any; - - const sectionBreakConfig = { - lists: [], - localeService: {} as any, - drawings: { - 'inline-1': { - drawingId: 'inline-1', - layoutType: PositionedObjectLayoutType.INLINE, - }, - 'float-1': { - drawingId: 'float-1', - layoutType: PositionedObjectLayoutType.WRAP_SQUARE, - }, - }, - } as any; - - const shapedTextList = [ - { - text: `A${DataStreamTreeTokenType.PAGE_BREAK}`, - glyphs: [{ content: 'A' }], - breakPointType: 0, - }, - { - text: `B${DataStreamTreeTokenType.COLUMN_BREAK}`, - glyphs: [{ content: 'B' }], - breakPointType: 0, - }, - { - text: 'C', - glyphs: [{ content: 'C' }], - breakPointType: 0, - }, - ] as any; - - const tableSkeleton = { tableId: 'table-1' } as any; - const pages = lineBreaking( - ctx, - viewModel, - shapedTextList, - curPage, - paragraphNode, - sectionBreakConfig, - tableSkeleton - ); - - expect(dealWithBulletMock).toHaveBeenCalled(); - expect(layoutParagraphMock).toHaveBeenCalled(); - expect(createSkeletonPageMock).toHaveBeenCalledWith( - ctx, - sectionBreakConfig, - ctx.skeletonResourceReference, - 2, - BreakType.PAGE - ); - expect(setColumnFullStateMock).toHaveBeenCalled(); - expect(ctx.paragraphConfigCache.get('segment-1')?.has(10)).toBe(true); - expect(ctx.skeletonResourceReference.drawingAnchor.get('segment-1')).toBeTruthy(); - expect(pages.length).toBeGreaterThan(0); + expect(result.length).toBeGreaterThanOrEqual(1); }); - it('reuses cached bullet skeleton and creates new page for last column', () => { - const ctx = createContext(); - const cachedSegment = new Map(); - cachedSegment.set(10, { bulletSkeleton: { id: 'cached-bullet' } }); - ctx.paragraphConfigCache.set('segment-1', cachedSegment); - - const viewModel = createViewModel(); - getLastNotFullColumnInfoMock.mockReturnValue({ - column: { id: 'c-last' }, - isLast: true, + it('handles bullet list paragraphs', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed('List item', { + body: { + dataStream: 'List item\r\n', + textRuns: [{ st: 0, ed: 11, ts: {} }], + paragraphs: [{ + startIndex: 9, + bullet: { + listId: 'list-1', + listType: 'test-list', + nestingLevel: 0, + }, + }], + sectionBreaks: [{ startIndex: 10 }], + }, + lists: { + 'test-list': { + listType: 'test-list', + nestingLevel: [{ + bulletAlignment: 1, + glyphFormat: '%1.', + startNumber: 1, + glyphType: 0, + }], + }, + }, }); + const shapedTextList = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); - const pages = lineBreaking( - ctx, - viewModel, - [{ - text: `X${DataStreamTreeTokenType.COLUMN_BREAK}`, - glyphs: [{ content: 'X' }], - breakPointType: 0, - }] as any, - { - segmentId: 'segment-1', - pageNumber: 5, - } as any, - { - endIndex: 10, - startIndex: 0, - blocks: [], - children: [], - } as any, - { - lists: [], - localeService: {} as any, - drawings: {}, - } as any, - null - ); + const result = lineBreaking(ctx, viewModel, shapedTextList, curPage, paragraphNode, sectionBreakConfig, null); - expect(dealWithBulletMock).not.toHaveBeenCalled(); - expect(createSkeletonPageMock).toHaveBeenCalledWith( - ctx, - expect.any(Object), - ctx.skeletonResourceReference, - 6, - BreakType.COLUMN - ); - expect(pages.length).toBeGreaterThanOrEqual(1); + expect(result.length).toBeGreaterThanOrEqual(1); + }); + + it('handles empty shaped text list', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed(''); + + const result = lineBreaking(ctx, viewModel, [], curPage, paragraphNode, sectionBreakConfig, null); + + expect(result.length).toBe(1); }); }); diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/paragraph-layout.spec.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/paragraph-layout.spec.ts new file mode 100644 index 0000000000..0420b8234e --- /dev/null +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/paragraph-layout.spec.ts @@ -0,0 +1,64 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; +import { dealWidthParagraph } from '../paragraph-layout'; +import { createParagraphLayoutTestBed } from './create-paragraph-layout-test-bed'; + +describe('paragraph-layout', () => { + it('lays out a normal text paragraph', () => { + const { ctx, viewModel, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed('Hello world'); + + const result = dealWidthParagraph(ctx, viewModel, paragraphNode, curPage, sectionBreakConfig); + + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result[0].sections.length).toBeGreaterThan(0); + }); + + it('lays out empty paragraph', () => { + const { ctx, viewModel, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed(''); + + const result = dealWidthParagraph(ctx, viewModel, paragraphNode, curPage, sectionBreakConfig); + + expect(result.length).toBeGreaterThanOrEqual(1); + }); + + it('lays out CJK text paragraph', () => { + const { ctx, viewModel, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed('你好世界'); + + const result = dealWidthParagraph(ctx, viewModel, paragraphNode, curPage, sectionBreakConfig); + + expect(result.length).toBeGreaterThanOrEqual(1); + }); + + it('lays out paragraph with multiple words', () => { + const { ctx, viewModel, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed('This is a longer paragraph with multiple words to test layout'); + + const result = dealWidthParagraph(ctx, viewModel, paragraphNode, curPage, sectionBreakConfig); + + expect(result.length).toBeGreaterThanOrEqual(1); + }); + + it('returns pages with line structure', () => { + const { ctx, viewModel, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed('Line one\rLine two'); + + const result = dealWidthParagraph(ctx, viewModel, paragraphNode, curPage, sectionBreakConfig); + + expect(result.length).toBeGreaterThanOrEqual(1); + const lastPage = result[result.length - 1]; + expect(lastPage.sections.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/shaping.spec.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/shaping.spec.ts index 781917a899..07ce459c11 100644 --- a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/shaping.spec.ts +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/shaping.spec.ts @@ -14,326 +14,328 @@ * limitations under the License. */ -import { BooleanNumber, DataStreamTreeTokenType, GridType, PositionedObjectLayoutType } from '@univerjs/core'; +import { BooleanNumber, DataStreamTreeTokenType, PositionedObjectLayoutType } from '@univerjs/core'; import { describe, expect, it, vi } from 'vitest'; import { Lang } from '../../../hyphenation/lang'; - +import { fontLibrary } from '../../../shaping-engine/font-library'; +import * as textShapingModule from '../../../shaping-engine/text-shaping'; import { shaping } from '../shaping'; +import { createParagraphLayoutTestBed } from './create-paragraph-layout-test-bed'; -const h = vi.hoisted(() => ({ - createSkeletonCustomBlockGlyphMock: vi.fn(), - createSkeletonLetterGlyphMock: vi.fn(), - createSkeletonTabGlyphMock: vi.fn(), - glyphShrinkLeftMock: vi.fn(), - glyphShrinkRightMock: vi.fn(), - getBoundingBoxMock: vi.fn(() => ({ - width: 12, - height: 6, - })), - textShapeMock: vi.fn(), - prepareParagraphBodyMock: vi.fn(() => 'paragraph-body'), - getCharSpaceApplyMock: vi.fn(() => 2), - getFontCreateConfigMock: vi.fn((index: number) => ({ index })), - emojiHandlerMock: vi.fn(), - arabicHandlerMock: vi.fn(), - tibetanHandlerMock: vi.fn(), - otherHandlerMock: vi.fn(), - hyphenEnhancerCtorMock: vi.fn(), - fontLibraryMock: { - isReady: false, - }, -})); +describe('shaping', () => { + it('shapes plain English text', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed('Hello world'); -function createGlyph(content: string, width = 8, streamType?: string) { - return { - content, - width, - xOffset: 0, - streamType, - adjustability: { - shrinkability: [6, 6], - stretchability: [1, 1], - }, - bBox: { - width: Math.max(width - 1, 1), - }, - } as any; -} + const result = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); -vi.mock('../../../../../../basics/tools', () => ({ - hasArabic: (char: string) => char === 'ع', - hasCJK: (char: string) => char === '中' || char === ',', - hasCJKPunctuation: (char: string) => char === ',', - hasCJKText: (char: string) => char === '中' || char === ',', - hasTibetan: (char: string) => char === 'ཀ', - startWithEmoji: (src: string) => src.startsWith('😀'), -})); + expect(result.length).toBeGreaterThan(0); + expect(result[0].text).toBeDefined(); + expect(result[0].glyphs.length).toBeGreaterThan(0); + }); -vi.mock('../../../line-breaker', () => ({ - LineBreaker: class { - private _used = false; + it('shapes text with spaces', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed('Hello world test'); - constructor(private _content: string) {} + const result = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); - nextBreakPoint() { - if (this._used) { - return null; - } - this._used = true; - return { - position: this._content.length, - type: 0, - }; + expect(result.length).toBeGreaterThan(0); + const allGlyphs = result.flatMap((r) => r.glyphs); + expect(allGlyphs.length).toBeGreaterThan(0); + }); + + it('shapes text with tab characters', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed('Hello\tworld'); + + const result = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); + + expect(result.length).toBeGreaterThan(0); + const allGlyphs = result.flatMap((r) => r.glyphs); + const tabGlyph = allGlyphs.find((g) => g.content === '\t'); + expect(tabGlyph).toBeDefined(); + }); + + it('shapes CJK text', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed('你好世界'); + + const result = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); + + expect(result.length).toBeGreaterThan(0); + const allGlyphs = result.flatMap((r) => r.glyphs); + expect(allGlyphs.length).toBeGreaterThan(0); + }); + + it('shapes mixed CJK and Latin text', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed('Hello你好'); + + const result = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); + + expect(result.length).toBeGreaterThan(0); + const allGlyphs = result.flatMap((r) => r.glyphs); + expect(allGlyphs.length).toBeGreaterThan(0); + }); + + it('shapes text with emoji', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed('Hello \uD83D\uDE00'); + + const result = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); + + expect(result.length).toBeGreaterThan(0); + const allGlyphs = result.flatMap((r) => r.glyphs); + const emojiGlyph = allGlyphs.find((g) => g.content === '\uD83D\uDE00'); + expect(emojiGlyph).toBeDefined(); + }); + + it('shapes text with Arabic characters', () => { + const arabicText = '\u0645\u0631\u062D\u0628\u0627'; // 'مرحبا' + const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed(arabicText); + + const result = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); + + expect(result.length).toBeGreaterThan(0); + const allGlyphs = result.flatMap((r) => r.glyphs); + expect(allGlyphs.length).toBeGreaterThan(0); + }); + + it('returns breakPointType for each shaped text', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed('Hello world'); + + const result = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); + + expect(result.length).toBeGreaterThan(0); + for (const shapedText of result) { + expect(shapedText.breakPointType).toBeDefined(); } - }, -})); + }); -vi.mock('../../../line-breaker/enhancers/link-enhancer', () => ({ - LineBreakerLinkEnhancer: class { - constructor(private _breaker: any) {} + it('shapes Tibetan text', () => { + const tibetanText = '\u0F40\u0F41'; + const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed(tibetanText); - nextBreakPoint() { - return this._breaker.nextBreakPoint(); - } - }, -})); + const result = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); -vi.mock('../../../line-breaker/enhancers/hyphen-enhancer', () => ({ - LineBreakerHyphenEnhancer: class { - constructor(private _breaker: any) { - h.hyphenEnhancerCtorMock(); - } + expect(result.length).toBeGreaterThan(0); + const allGlyphs = result.flatMap((r) => r.glyphs); + expect(allGlyphs.length).toBeGreaterThan(0); + }); - nextBreakPoint() { - return this._breaker.nextBreakPoint(); - } - }, -})); + it('shapes Thai text', () => { + const thaiText = '\u0E01\u0E02'; + const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed(thaiText); -vi.mock('../../../line-breaker/extensions/tab-linebreak-extension', () => ({ - tabLineBreakExtension: vi.fn(), -})); + const result = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); -vi.mock('../../../line-breaker/extensions/custom-block-linebreak-extension', () => ({ - customBlockLineBreakExtension: vi.fn(), -})); + expect(result.length).toBeGreaterThan(0); + const allGlyphs = result.flatMap((r) => r.glyphs); + expect(allGlyphs.length).toBeGreaterThan(0); + }); -vi.mock('../../../model/glyph', () => ({ - createSkeletonCustomBlockGlyph: (...args: any[]) => (h.createSkeletonCustomBlockGlyphMock as any)(...args), - createSkeletonLetterGlyph: (...args: any[]) => (h.createSkeletonLetterGlyphMock as any)(...args), - createSkeletonTabGlyph: (...args: any[]) => (h.createSkeletonTabGlyphMock as any)(...args), - glyphShrinkLeft: (...args: any[]) => (h.glyphShrinkLeftMock as any)(...args), - glyphShrinkRight: (...args: any[]) => (h.glyphShrinkRightMock as any)(...args), -})); + it('applies punctuation space adjustment for consecutive CJK punctuation', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed(',。'); -vi.mock('../../../model/line', () => ({ - getBoundingBox: (...args: any[]) => (h.getBoundingBoxMock as any)(...args), -})); + const result = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); -vi.mock('../../../shaping-engine/text-shaping', () => ({ - textShape: (...args: any[]) => (h.textShapeMock as any)(...args), -})); + const allGlyphs = result.flatMap((r) => r.glyphs); + expect(allGlyphs.some((g) => g.content === ',')).toBe(true); + expect(allGlyphs.some((g) => g.content === '。')).toBe(true); + }); -vi.mock('../../../shaping-engine/utils', () => ({ - prepareParagraphBody: (...args: any[]) => (h.prepareParagraphBodyMock as any)(...args), -})); + it('adds CJK Latin spacing for mixed text', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed('A好B'); -vi.mock('../../../shaping-engine/font-library', () => ({ - fontLibrary: h.fontLibraryMock, -})); + const result = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); -vi.mock('../../../tools', () => ({ - getCharSpaceApply: (...args: any[]) => (h.getCharSpaceApplyMock as any)(...args), - getFontCreateConfig: (...args: any[]) => (h.getFontCreateConfigMock as any)(...args), -})); + expect(result.length).toBeGreaterThan(0); + const allGlyphs = result.flatMap((r) => r.glyphs); + expect(allGlyphs.length).toBeGreaterThan(0); + }); -vi.mock('../language-ruler', () => ({ - emojiHandler: (...args: any[]) => (h.emojiHandlerMock as any)(...args), - ArabicHandler: (...args: any[]) => (h.arabicHandlerMock as any)(...args), - TibetanHandler: (...args: any[]) => (h.tibetanHandlerMock as any)(...args), - otherHandler: (...args: any[]) => (h.otherHandlerMock as any)(...args), -})); + it('shapes paragraph break with zero width when configured', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed('Hello'); + sectionBreakConfig.renderConfig = { + ...sectionBreakConfig.renderConfig, + zeroWidthParagraphBreak: BooleanNumber.TRUE, + }; -describe('paragraph shaping', () => { - it('handles custom blocks, language handlers, punctuation and hyphen pattern loading', () => { - h.fontLibraryMock.isReady = false; - h.hyphenEnhancerCtorMock.mockClear(); - h.textShapeMock.mockReset(); + const result = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); - h.createSkeletonLetterGlyphMock.mockImplementation((char: string, _config: any, width?: number) => { - if (char === DataStreamTreeTokenType.PARAGRAPH && width === 0) { - return createGlyph(char, 0); - } - return createGlyph(char, 8); - }); - h.createSkeletonTabGlyphMock.mockImplementation(() => createGlyph(DataStreamTreeTokenType.TAB, 4)); - h.createSkeletonCustomBlockGlyphMock.mockImplementation((_config: any, width: number, _height: number, _drawingId: string) => { - return createGlyph(DataStreamTreeTokenType.CUSTOM_BLOCK, width, DataStreamTreeTokenType.CUSTOM_BLOCK); - }); - h.glyphShrinkLeftMock.mockImplementation((glyph: any, delta: number) => { - glyph.width -= delta; - }); - h.glyphShrinkRightMock.mockImplementation((glyph: any, delta: number) => { - glyph.width -= delta; - }); + const allGlyphs = result.flatMap((r) => r.glyphs); + const paragraphGlyph = allGlyphs.find((g) => g.content === '\r'); + expect(paragraphGlyph).toBeDefined(); + expect(paragraphGlyph!.width).toBe(0); + }); - h.emojiHandlerMock.mockImplementation((_i: number, _src: string) => ({ - step: '😀'.length, - glyphGroup: [createGlyph('😀', 10)], - })); - h.arabicHandlerMock.mockImplementation(() => ({ - step: 1, - glyphGroup: [createGlyph('ع', 9)], - })); - h.tibetanHandlerMock.mockImplementation(() => ({ - step: 1, - glyphGroup: [createGlyph('ཀ', 9)], - })); - h.otherHandlerMock.mockImplementation(() => ({ - step: 1, - glyphGroup: [createGlyph('x', 7)], - })); + it('shapes custom block when drawing is not found', () => { + const content = `A${DataStreamTreeTokenType.CUSTOM_BLOCK}B`; + const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed(content); - const ctx = { - hyphen: { - hasPattern: vi.fn(() => false), - loadPattern: vi.fn(), + const result = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); + + const allGlyphs = result.flatMap((r) => r.glyphs); + expect(allGlyphs.length).toBeGreaterThan(0); + }); + + it('shapes inline custom block and splits shaped texts', () => { + const content = `A${DataStreamTreeTokenType.CUSTOM_BLOCK}B`; + const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed(content, { + body: { + customBlocks: [{ startIndex: 1, blockId: 'b1' }], }, - languageDetector: { - detect: vi.fn(() => Lang.EnUs), - }, - } as any; - - const viewModel = { - getParagraph: vi.fn(() => ({ - startIndex: 0, - paragraphStyle: { - snapToGrid: BooleanNumber.TRUE, - suppressHyphenation: BooleanNumber.FALSE, - }, - })), - getBody: vi.fn(() => ({ dataStream: '' })), - getCustomBlockWithoutSetCurrentIndex: vi.fn((index: number) => { - if (index === 0) { - return { blockId: 'inline-block' }; - } - if (index === 1) { - return { blockId: 'float-block' }; - } - return null; - }), - } as any; - - const paragraphNode = { - startIndex: 0, - endIndex: 1, - } as any; - - const sectionBreakConfig = { - gridType: GridType.LINES, - charSpace: 1, - defaultTabStop: 10.5, drawings: { - 'inline-block': { - drawingId: 'inline-block', + b1: { + drawingId: 'd1', layoutType: PositionedObjectLayoutType.INLINE, docTransform: { - angle: 30, - size: { - width: 20, - height: 10, - }, + angle: 0, + size: { width: 100, height: 100 }, }, }, - 'float-block': { - drawingId: 'float-block', - layoutType: PositionedObjectLayoutType.WRAP_SQUARE, + }, + }); + + const result = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); + + const allGlyphs = result.flatMap((r) => r.glyphs); + const customBlockGlyph = allGlyphs.find((g) => g.streamType === DataStreamTreeTokenType.CUSTOM_BLOCK); + expect(customBlockGlyph).toBeDefined(); + expect(customBlockGlyph!.width).toBeGreaterThan(0); + expect(result.length).toBeGreaterThan(1); + }); + + it('shapes floating custom block without splitting', () => { + const content = `A${DataStreamTreeTokenType.CUSTOM_BLOCK}B`; + const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed(content, { + body: { + customBlocks: [{ startIndex: 1, blockId: 'b1' }], + }, + drawings: { + b1: { + drawingId: 'd1', + layoutType: PositionedObjectLayoutType.WRAP_NONE, docTransform: { angle: 0, - size: { - width: 20, - height: 10, - }, + size: { width: 100, height: 100 }, }, }, }, - autoHyphenation: BooleanNumber.TRUE, - doNotHyphenateCaps: BooleanNumber.FALSE, - renderConfig: { - zeroWidthParagraphBreak: BooleanNumber.TRUE, - }, - } as any; + }); - const content = `${DataStreamTreeTokenType.CUSTOM_BLOCK}${DataStreamTreeTokenType.CUSTOM_BLOCK}\t${DataStreamTreeTokenType.PARAGRAPH}中😀عཀx,,中a中`; - const shaped = shaping(ctx, content, viewModel, paragraphNode, sectionBreakConfig, false); + const result = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); - expect(shaped.length).toBeGreaterThan(0); - expect(h.getBoundingBoxMock).toHaveBeenCalled(); - expect(h.createSkeletonCustomBlockGlyphMock).toHaveBeenCalledTimes(2); - expect(h.emojiHandlerMock).toHaveBeenCalled(); - expect(h.arabicHandlerMock).toHaveBeenCalled(); - expect(h.tibetanHandlerMock).toHaveBeenCalled(); - expect(h.otherHandlerMock).toHaveBeenCalled(); - expect(h.glyphShrinkLeftMock).toHaveBeenCalled(); - expect(h.glyphShrinkRightMock).toHaveBeenCalled(); - expect(ctx.hyphen.loadPattern).toHaveBeenCalledWith(Lang.EnUs); - expect(h.hyphenEnhancerCtorMock).not.toHaveBeenCalled(); + const allGlyphs = result.flatMap((r) => r.glyphs); + const customBlockGlyph = allGlyphs.find((g) => g.streamType === DataStreamTreeTokenType.CUSTOM_BLOCK); + expect(customBlockGlyph).toBeDefined(); + expect(customBlockGlyph!.width).toBe(0); }); - it('uses openType shaping path and hyphen enhancer when pattern exists', () => { - h.fontLibraryMock.isReady = true; - h.hyphenEnhancerCtorMock.mockClear(); + it('shapes text with useOpenType when font library is ready', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed('Hello'); + const originalIsReady = fontLibrary.isReady; + fontLibrary.isReady = true; - h.createSkeletonLetterGlyphMock.mockImplementation((char: string, _config: any, _width?: number, glyphInfo?: any) => { - return createGlyph(glyphInfo ? `${char}-ot` : char, 8); - }); - h.createSkeletonTabGlyphMock.mockImplementation(() => createGlyph(DataStreamTreeTokenType.TAB, 4)); - h.textShapeMock.mockReturnValue([ - { start: 0, end: 1, char: 'A' }, - { start: 1, end: 2, char: DataStreamTreeTokenType.TAB }, - { start: 2, end: 4, char: '😀' }, + try { + const result = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig, true); + expect(result.length).toBeGreaterThan(0); + } finally { + fontLibrary.isReady = originalIsReady; + } + }); + + it('shapes tab with useOpenType when font library is ready', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed('Hello\tworld'); + const originalIsReady = fontLibrary.isReady; + fontLibrary.isReady = true; + + const spy = vi.spyOn(textShapingModule, 'textShape').mockReturnValue([ + { char: 'H', start: 0, end: 1, glyph: null, font: null, kerning: 0, boundingBox: null }, + { char: 'e', start: 1, end: 2, glyph: null, font: null, kerning: 0, boundingBox: null }, + { char: 'l', start: 2, end: 3, glyph: null, font: null, kerning: 0, boundingBox: null }, + { char: 'l', start: 3, end: 4, glyph: null, font: null, kerning: 0, boundingBox: null }, + { char: 'o', start: 4, end: 5, glyph: null, font: null, kerning: 0, boundingBox: null }, + { char: '\t', start: 5, end: 6, glyph: null, font: null, kerning: 0, boundingBox: null }, + { char: 'w', start: 6, end: 7, glyph: null, font: null, kerning: 0, boundingBox: null }, + { char: 'o', start: 7, end: 8, glyph: null, font: null, kerning: 0, boundingBox: null }, + { char: 'r', start: 8, end: 9, glyph: null, font: null, kerning: 0, boundingBox: null }, + { char: 'l', start: 9, end: 10, glyph: null, font: null, kerning: 0, boundingBox: null }, + { char: 'd', start: 10, end: 11, glyph: null, font: null, kerning: 0, boundingBox: null }, + { char: '\r', start: 11, end: 12, glyph: null, font: null, kerning: 0, boundingBox: null }, ]); - const ctx = { - hyphen: { - hasPattern: vi.fn(() => true), - loadPattern: vi.fn(), - }, - languageDetector: { - detect: vi.fn(() => Lang.EnUs), - }, - } as any; + try { + const result = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig, true); + const allGlyphs = result.flatMap((r) => r.glyphs); + const tabGlyph = allGlyphs.find((g) => g.content === '\t'); + expect(tabGlyph).toBeDefined(); + } finally { + spy.mockRestore(); + fontLibrary.isReady = originalIsReady; + } + }); - const viewModel = { - getParagraph: vi.fn(() => ({ - startIndex: 0, - paragraphStyle: { - suppressHyphenation: BooleanNumber.FALSE, - }, - })), - getBody: vi.fn(() => ({ dataStream: '' })), - getCustomBlockWithoutSetCurrentIndex: vi.fn(() => null), - } as any; + it('shapes emoji with useOpenType when font library is ready', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed('Hello \uD83D\uDE00'); + const originalIsReady = fontLibrary.isReady; + fontLibrary.isReady = true; - const shaped = shaping( - ctx, - `A${DataStreamTreeTokenType.TAB}😀`, - viewModel, - { startIndex: 0, endIndex: 1 } as any, - { - autoHyphenation: BooleanNumber.TRUE, - doNotHyphenateCaps: BooleanNumber.TRUE, - drawings: {}, - } as any, - true - ); + const spy = vi.spyOn(textShapingModule, 'textShape').mockReturnValue([ + { char: 'H', start: 0, end: 1, glyph: null, font: null, kerning: 0, boundingBox: null }, + { char: 'e', start: 1, end: 2, glyph: null, font: null, kerning: 0, boundingBox: null }, + { char: 'l', start: 2, end: 3, glyph: null, font: null, kerning: 0, boundingBox: null }, + { char: 'l', start: 3, end: 4, glyph: null, font: null, kerning: 0, boundingBox: null }, + { char: 'o', start: 4, end: 5, glyph: null, font: null, kerning: 0, boundingBox: null }, + { char: ' ', start: 5, end: 6, glyph: null, font: null, kerning: 0, boundingBox: null }, + { char: '\uD83D\uDE00', start: 6, end: 8, glyph: null, font: null, kerning: 0, boundingBox: null }, + { char: '\r', start: 8, end: 9, glyph: null, font: null, kerning: 0, boundingBox: null }, + ]); - expect(shaped.length).toBeGreaterThan(0); - expect(h.prepareParagraphBodyMock).toHaveBeenCalled(); - expect(h.textShapeMock).toHaveBeenCalled(); - expect(h.createSkeletonTabGlyphMock).toHaveBeenCalled(); - expect(h.createSkeletonLetterGlyphMock).toHaveBeenCalled(); - expect(h.hyphenEnhancerCtorMock).toHaveBeenCalled(); - expect(ctx.hyphen.loadPattern).not.toHaveBeenCalled(); + try { + const result = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig, true); + const allGlyphs = result.flatMap((r) => r.glyphs); + const emojiGlyph = allGlyphs.find((g) => g.content === '\uD83D\uDE00'); + expect(emojiGlyph).toBeDefined(); + } finally { + spy.mockRestore(); + fontLibrary.isReady = originalIsReady; + } + }); + + it('loads hyphen pattern when language pattern is not available', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed('test'); + sectionBreakConfig.autoHyphenation = BooleanNumber.TRUE; + const paragraph = viewModel.getParagraph(paragraphNode.endIndex)!; + paragraph.paragraphStyle = { ...paragraph.paragraphStyle, suppressHyphenation: BooleanNumber.FALSE }; + + const fakeHyphen = { + hasPattern: vi.fn(() => false), + loadPattern: vi.fn(() => Promise.resolve()), + fetchHyphenCache: vi.fn(), + hyphenate: vi.fn(), + dispose: vi.fn(), + }; + ctx.hyphen = fakeHyphen as any; + ctx.languageDetector = { detect: vi.fn(() => Lang.Fr), dispose: vi.fn() } as any; + + shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); + expect(fakeHyphen.loadPattern).toHaveBeenCalledWith(Lang.Fr); + }); + + it('uses hyphen enhancer when hyphenation is enabled and pattern exists', () => { + const { viewModel, ctx, paragraphNode, sectionBreakConfig } = createParagraphLayoutTestBed('hyphenation'); + sectionBreakConfig.autoHyphenation = BooleanNumber.TRUE; + const paragraph = viewModel.getParagraph(paragraphNode.endIndex)!; + paragraph.paragraphStyle = { ...paragraph.paragraphStyle, suppressHyphenation: BooleanNumber.FALSE }; + + const fakeHyphen = { + hasPattern: vi.fn(() => true), + loadPattern: vi.fn(() => Promise.resolve()), + fetchHyphenCache: vi.fn(), + hyphenate: vi.fn((word: string) => [word]), + dispose: vi.fn(), + }; + ctx.hyphen = fakeHyphen as any; + ctx.languageDetector = { detect: vi.fn(() => Lang.EnUs), dispose: vi.fn() } as any; + + const result = shaping(ctx, paragraphNode.content!, viewModel, paragraphNode, sectionBreakConfig); + expect(result.length).toBeGreaterThan(0); + expect(fakeHyphen.loadPattern).not.toHaveBeenCalled(); }); }); diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/language-ruler.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/language-ruler.ts index 5f4a03b79b..d38f727242 100644 --- a/packages/engine-render/src/components/docs/layout/block/paragraph/language-ruler.ts +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/language-ruler.ts @@ -19,7 +19,7 @@ import type { IParagraph } from '@univerjs/core'; import type { ISectionBreakConfig } from '../../../../../basics/interfaces'; import type { DataStreamTreeNode } from '../../../view-model/data-stream-tree-node'; import type { DocumentViewModel } from '../../../view-model/document-view-model'; -import { getFirstGrapheme, hasArabic, hasSpace, hasTibetan, startWithEmoji } from '../../../../../basics/tools'; +import { getFirstGrapheme, hasArabic, hasSpace, hasThai, hasTibetan, startWithEmoji } from '../../../../../basics/tools'; import { createSkeletonLetterGlyph, createSkeletonWordGlyph } from '../../model/glyph'; import { getFontCreateConfig } from '../../tools'; @@ -137,3 +137,31 @@ export function TibetanHandler( glyphGroup: [createSkeletonWordGlyph(glyph.join(''), config)], }; } + +export function ThaiHandler( + index: number, + charArray: string, + viewModel: DocumentViewModel, + paragraphNode: DataStreamTreeNode, + sectionBreakConfig: ISectionBreakConfig, + paragraph: IParagraph +) { + // Combine Thai phrases so complex text layout works correctly. + const config = getFontCreateConfig(index, viewModel, paragraphNode, sectionBreakConfig, paragraph); + const glyph = []; + let step = 0; + for (let i = 0; i < charArray.length; i++) { + const newChar = charArray[i]; + if (hasThai(newChar)) { + glyph.push(newChar); + step++; + } else { + break; + } + } + + return { + step, + glyphGroup: [createSkeletonWordGlyph(glyph.join(''), config)], + }; +} diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/shaping.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/shaping.ts index af3913c68c..8812712475 100644 --- a/packages/engine-render/src/components/docs/layout/block/paragraph/shaping.ts +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/shaping.ts @@ -22,7 +22,7 @@ import type { DocumentViewModel } from '../../../view-model/document-view-model' import type { IOpenTypeGlyphInfo } from '../../shaping-engine/text-shaping'; import type { ILayoutContext } from '../../tools'; import { BooleanNumber, DataStreamTreeTokenType, GridType, PositionedObjectLayoutType } from '@univerjs/core'; -import { hasArabic, hasCJK, hasCJKPunctuation, hasCJKText, hasTibetan, startWithEmoji } from '../../../../../basics/tools'; +import { hasArabic, hasCJK, hasCJKPunctuation, hasCJKText, hasThai, hasTibetan, startWithEmoji } from '../../../../../basics/tools'; import { Lang } from '../../hyphenation/lang'; import { LineBreaker } from '../../line-breaker'; import { BreakPointType } from '../../line-breaker/break'; @@ -36,7 +36,7 @@ import { fontLibrary } from '../../shaping-engine/font-library'; import { textShape } from '../../shaping-engine/text-shaping'; import { prepareParagraphBody } from '../../shaping-engine/utils'; import { getCharSpaceApply, getFontCreateConfig } from '../../tools'; -import { ArabicHandler, emojiHandler, otherHandler, TibetanHandler } from './language-ruler'; +import { ArabicHandler, emojiHandler, otherHandler, ThaiHandler, TibetanHandler } from './language-ruler'; // Now we apply consecutive punctuation adjustment, specified in Chinese Layout // Requirements, section 3.1.6.1 Punctuation Adjustment Space, and Japanese Layout @@ -298,6 +298,19 @@ export function shaping( shapedGlyphs.push(...glyphGroup); i += step; + src = src.substring(step); + } else if (hasThai(char)) { + const { step, glyphGroup } = ThaiHandler( + i, + src, + viewModel, + paragraphNode, + sectionBreakConfig, + paragraph + ); + shapedGlyphs.push(...glyphGroup); + i += step; + src = src.substring(step); } else { const { step, glyphGroup } = otherHandler(