Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"@univerjs/watermark": "workspace:*",
"lit": "^3.3.2",
"monaco-editor": "0.55.1",
"opentype.js": "^1.3.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-mosaic-component": "^6.1.1",
Expand All @@ -94,7 +95,7 @@
"esbuild-plugin-copy": "^2.1.1",
"esbuild-plugin-vue3": "^0.5.1",
"esbuild-style-plugin": "^1.6.3",
"fs-extra": "^11.3.3",
"fs-extra": "^11.3.4",
"minimist": "^1.2.8",
"postcss": "^8.5.6",
"tailwindcss": "3.4.18",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"type": "module",
"version": "0.16.1",
"private": true,
"packageManager": "pnpm@10.30.2",
"packageManager": "pnpm@10.30.3",
"author": "DreamNum Co., Ltd. <developer@univer.ai>",
"license": "Apache-2.0",
"funding": {
Expand Down
6 changes: 4 additions & 2 deletions packages/engine-render/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,13 @@
"@floating-ui/utils": "^0.2.10",
"@univerjs/core": "workspace:*",
"cjk-regex": "^3.4.0",
"franc-min": "^6.2.0",
"franc-min": "^6.2.0"
},
"optionalDependencies": {
"opentype.js": "^1.3.4"
},
"devDependencies": {
"@types/opentype.js": "^1.3.8",
"@types/opentype.js": "^1.3.9",
"@univerjs-infra/shared": "workspace:*",
"jest-canvas-mock": "^2.5.2",
"rxjs": "^7.8.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* 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 type { DataStreamTreeNode } from '../../../../view-model/data-stream-tree-node';
import type { DocumentViewModel } from '../../../../view-model/document-view-model';
import type { IOpenTypeGlyphInfo } from '../../../shaping-engine/text-shaping';
import type { ILayoutContext } from '../../../tools';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Lang } from '../../../hyphenation/lang';

const SLOW_CI_TIMEOUT = 30_000;

function flattenShapedContent(shaped: Array<{ glyphs: Array<{ content: string }> }>) {
return shaped.flatMap((item) => item.glyphs.map((glyph) => glyph.content)).join('');
}

function createViewModel(content: string): DocumentViewModel {
return {
getParagraph: () => ({
startIndex: 0,
paragraphStyle: {},
}),
getBody: () => ({
dataStream: `${content}\r`,
paragraphs: [{ startIndex: content.length }],
textRuns: [],
}),
getTextRun: () => null,
getCustomDecoration: () => null,
getCustomRange: () => null,
getCustomBlockWithoutSetCurrentIndex: () => null,
} as unknown as DocumentViewModel;
}

function createContext(): ILayoutContext {
return {
hyphen: {
hasPattern: () => false,
loadPattern: () => Promise.resolve(),
},
languageDetector: {
detect: () => Lang.UNKNOWN,
},
} as unknown as ILayoutContext;
}

function createParagraphNode(content: string): DataStreamTreeNode {
return {
startIndex: 0,
endIndex: content.length,
} as unknown as DataStreamTreeNode;
}

describe('shaping with useOpenType switch', () => {
afterEach(() => {
vi.resetModules();
vi.clearAllMocks();
vi.unmock('../../../shaping-engine/text-shaping');
vi.unmock('../../../shaping-engine/font-library');
});

it('should not invoke textShape when useOpenType is false, and fallback when true but no glyph infos', async () => {
const textShapeMock = vi.fn((): IOpenTypeGlyphInfo[] => []);

vi.doMock('../../../shaping-engine/text-shaping', () => ({
textShape: textShapeMock,
}));

vi.doMock('../../../shaping-engine/font-library', () => ({
fontLibrary: {
isReady: true,
},
}));

const { shaping } = await import('../shaping');
const content = 'Hello';
const ctx = createContext();
const viewModel = createViewModel(content);
const paragraphNode = createParagraphNode(content);
const sectionBreakConfig = {
drawings: {},
} as ISectionBreakConfig;

const shapedWithoutOpenType = shaping(ctx, content, viewModel, paragraphNode, sectionBreakConfig, false);
const shapedWithOpenTypeFallback = shaping(ctx, content, viewModel, paragraphNode, sectionBreakConfig, true);

expect(textShapeMock).toHaveBeenCalledTimes(1);
expect(flattenShapedContent(shapedWithoutOpenType)).toBe(content);
expect(flattenShapedContent(shapedWithOpenTypeFallback)).toBe(content);
expect(flattenShapedContent(shapedWithOpenTypeFallback)).toBe(flattenShapedContent(shapedWithoutOpenType));
}, SLOW_CI_TIMEOUT);

it('should use textShape glyph infos when useOpenType is true and glyph infos are available', async () => {
const content = 'AB CD';
const textShapeMock = vi.fn((): IOpenTypeGlyphInfo[] => {
const chars = content.match(/[\s\S]/gu) ?? [];
let start = 0;

return chars.map((char) => {
const info: IOpenTypeGlyphInfo = {
char,
start,
end: start + char.length,
glyph: null,
font: null,
kerning: 0,
boundingBox: null,
};

start += char.length;

return info;
});
});

vi.doMock('../../../shaping-engine/text-shaping', () => ({
textShape: textShapeMock,
}));

vi.doMock('../../../shaping-engine/font-library', () => ({
fontLibrary: {
isReady: true,
},
}));

const { shaping } = await import('../shaping');
const ctx = createContext();
const viewModel = createViewModel(content);
const paragraphNode = createParagraphNode(content);
const sectionBreakConfig = {
drawings: {},
} as ISectionBreakConfig;

const shapedWithOpenType = shaping(ctx, content, viewModel, paragraphNode, sectionBreakConfig, true);

expect(textShapeMock).toHaveBeenCalledTimes(1);
expect(flattenShapedContent(shapedWithOpenType)).toBe(content);
}, SLOW_CI_TIMEOUT);
});
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export function dealWidthParagraph(
viewModel,
paragraphNode,
sectionBreakConfig
// true
);

// Step 2: Line Breaking.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,13 +134,13 @@ export function shaping(

const paragraphBody = prepareParagraphBody(viewModel.getBody()!, endIndex);

// const now = +new Date();
let glyphInfos: IOpenTypeGlyphInfo[] = [];
let useOpenTypeGlyphShaping = false;

if (useOpenType) {
glyphInfos = textShape(paragraphBody);
useOpenTypeGlyphShaping = glyphInfos.length > 0;
}
// console.log('Text Shaping Time:', +new Date() - now);

// Add custom extension for linebreak.
tabLineBreakExtension(breaker);
Expand Down Expand Up @@ -168,7 +168,7 @@ export function shaping(
const word = content.slice(last, bk.position);
const shapedGlyphs: IDocumentSkeletonGlyph[] = [];

if (fontLibrary.isReady && useOpenType) {
if (fontLibrary.isReady && useOpenTypeGlyphShaping) {
const glyphInfosInWord = [];

let i = 0;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* 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 { IDocumentBody } from '@univerjs/core';
import { afterEach, describe, expect, it, vi } from 'vitest';

const SLOW_CI_TIMEOUT = 30_000;

function createBody(dataStream: string): IDocumentBody {
return {
dataStream,
textRuns: [],
};
}

async function flushImportQueue() {
await Promise.resolve();
await Promise.resolve();
await vi.dynamicImportSettled();
}

describe('textShape optional opentype loading', () => {
afterEach(() => {
vi.resetModules();
vi.clearAllMocks();
vi.unmock('opentype.js/dist/opentype.module');
vi.unmock('../font-library');
});

it('should shape text after async opentype parser is loaded', async () => {
const parseMock = vi.fn(() => ({
unitsPerEm: 1000,
ascender: 800,
descender: -200,
stringToGlyphs: (content: string) => (content.match(/[\s\S]/gu) ?? []).map(() => ({
index: 1,
advanceWidth: 500,
getBoundingBox: () => ({ x1: 0, y1: -10, x2: 10, y2: 10 }),
})),
getKerningValue: () => 0,
}));

const findBestMatchFontByStyleMock = vi.fn(() => ({
font: {
fullName: 'MockFont',
},
buffer: new ArrayBuffer(8),
}));

vi.doMock('opentype.js/dist/opentype.module', () => ({
parse: parseMock,
}));

vi.doMock('../font-library', () => ({
fontLibrary: {
isReady: true,
getValidFontFamilies: () => ['MockFont'],
findBestMatchFontByStyle: findBestMatchFontByStyleMock,
},
}));

const { fontLibrary } = await import('../font-library');
expect(fontLibrary.isReady).toBe(true);

const { textShape } = await import('../text-shaping');
const body = createBody('AB');

expect(textShape(body)).toEqual([]);

await flushImportQueue();

const shaped = textShape(body);

expect(shaped.map((item) => item.char)).toEqual(['A', 'B']);
expect(shaped.map((item) => [item.start, item.end])).toEqual([[0, 1], [1, 2]]);
expect(parseMock).toHaveBeenCalledTimes(1);
expect(findBestMatchFontByStyleMock).toHaveBeenCalled();
}, SLOW_CI_TIMEOUT);

it('should gracefully return empty result when opentype module is unavailable', async () => {
const findBestMatchFontByStyleMock = vi.fn();

vi.doMock('opentype.js/dist/opentype.module', () => {
throw new Error('Cannot find module');
});

vi.doMock('../font-library', () => ({
fontLibrary: {
isReady: true,
getValidFontFamilies: () => ['MockFont'],
findBestMatchFontByStyle: findBestMatchFontByStyleMock,
},
}));

const { textShape } = await import('../text-shaping');
const body = createBody('AB');

expect(textShape(body)).toEqual([]);

await flushImportQueue();

expect(textShape(body)).toEqual([]);
expect(findBestMatchFontByStyleMock).not.toHaveBeenCalled();
}, SLOW_CI_TIMEOUT);

it('should fallback to per-character glyph infos when no valid font family exists', async () => {
const parseMock = vi.fn(() => ({
unitsPerEm: 1000,
ascender: 800,
descender: -200,
stringToGlyphs: () => [],
getKerningValue: () => 0,
}));

vi.doMock('opentype.js/dist/opentype.module', () => ({
parse: parseMock,
}));

vi.doMock('../font-library', () => ({
fontLibrary: {
isReady: true,
getValidFontFamilies: () => [],
findBestMatchFontByStyle: vi.fn(),
},
}));

const { fontLibrary } = await import('../font-library');
expect(fontLibrary.isReady).toBe(true);

const { textShape } = await import('../text-shaping');
const body = createBody('ABC');

expect(textShape(body)).toEqual([]);

await flushImportQueue();

const shaped = textShape(body);

expect(shaped.map((item) => item.char)).toEqual(['A', 'B', 'C']);
expect(shaped.map((item) => [item.start, item.end])).toEqual([[0, 1], [1, 2], [2, 3]]);
expect(shaped.every((item) => item.glyph == null && item.font == null)).toBe(true);
expect(parseMock).not.toHaveBeenCalled();
}, SLOW_CI_TIMEOUT);
});
Loading