mirror of
https://github.com/dream-num/univer.git
synced 2026-09-24 15:32:18 +08:00
fix(engine-render): add Thai language text rendering support (#6960)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+106
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+152
-90
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+209
@@ -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,
|
||||
};
|
||||
}
|
||||
+211
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+87
-236
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+80
-166
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+53
-205
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+64
@@ -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);
|
||||
});
|
||||
});
|
||||
+275
-273
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+29
-1
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user