fix(engine-render): add Thai language text rendering support (#6960)

This commit is contained in:
白熱
2026-05-27 16:00:24 +08:00
committed by GitHub
parent 788a8fb283
commit 53dd8482fc
16 changed files with 1568 additions and 1138 deletions
@@ -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', () => {
@@ -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;
@@ -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([]);
});
});
@@ -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);
});
});
});
@@ -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> = {}): 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<string, any[][]>([
['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<string, IParagraphList[][]>([
['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<string, IParagraphList[][]>([
['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<string, IParagraphList[][]>();
const tableNode = {
startIndex: 10,
endIndex: 20,
} as DataStreamTreeNode;
expect(() => rollbackListCache(listLevel, tableNode)).not.toThrow();
});
});
});
@@ -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');
});
});
});
@@ -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> = {}): 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> = {}): 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<Record<string, unknown> | null> = [
{ startIndexItem: 2, symbol: '2.' },
null,
];
const result = dealWithBullet(bullet, lists as unknown as ILists, listLevelAncestors as unknown as Parameters<typeof dealWithBullet>[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<Record<string, unknown> | null> = [
{ startIndexItem: 5, symbol: '5.' },
];
const result = dealWithBullet(bullet, lists as unknown as ILists, listLevelAncestors as unknown as Parameters<typeof dealWithBullet>[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);
});
});
});
@@ -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<string, unknown>; documentStyle?: Record<string, unknown>; [key: string]: unknown } = {}
): DocumentDataModel {
const dataStream = `${content}\r\n`;
const bodyOverride = (overrides.body ?? {}) as Record<string, unknown>;
const documentStyleOverride = (overrides.documentStyle ?? {}) as Record<string, unknown>;
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<IDocumentData>);
}
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<string, unknown>; documentStyle?: Record<string, unknown>; [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<string, unknown>; documentStyle?: Record<string, unknown>; [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<string, unknown>;
const documentStyleOverride = (overrides.documentStyle ?? {}) as Record<string, unknown>;
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<IDocumentData>);
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,
};
}
@@ -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<ISectionBreakConfig['localeService']>,
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);
});
});
});
@@ -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<Parameters<typeof updateInlineDrawingPosition>[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);
});
});
@@ -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();
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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();
});
});
@@ -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)],
};
}
@@ -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(