fix(engine-render): use model-authoritative slide rendering (#7378)

This commit is contained in:
Univer
2026-08-03 02:13:06 +08:00
committed by GitHub
parent 05f18137b4
commit 08887b1f6c
27 changed files with 537 additions and 41 deletions
@@ -1,6 +1,6 @@
import type { Univer } from '@univerjs/core';
import type { FUniver } from '@univerjs/core/facade';
import { awaitTime, Disposable, Inject, IUniverInstanceService, ThemeService, UniverInstanceType } from '@univerjs/core';
import { awaitTime, Disposable, DocumentFlavor, Inject, IUniverInstanceService, ThemeService, UniverInstanceType } from '@univerjs/core';
import { DEFAULT_WORKBOOK_DATA_DEMO, DEFAULT_WORKBOOK_DATA_DEMO_DEFAULT_STYLE } from '@univerjs/mockdata';
import { getDefaultDocData } from './data/default-doc';
import { getDefaultWorkbookData } from './data/default-sheet';
@@ -17,6 +17,7 @@ export interface IE2EControllerAPI {
loadMergeCellSheet(loadTimeout?: number): Promise<void>;
loadDefaultStyleSheet(loadTimeout?: number): Promise<void>;
loadDefaultDoc(loadTimeout?: number,): Promise<void>;
loadDocLayoutFixture(documentFlavor: DocumentFlavor, loadTimeout?: number): Promise<void>;
setDarkMode(darkMode: boolean): void;
disposeUniver(): Promise<void>;
disposeCurrSheetUnit(disposeTimeout?: number): Promise<void>;
@@ -59,6 +60,7 @@ export class E2EController extends Disposable {
disposeCurrSheetUnit: (disposeTimeout?: number) => this._disposeDefaultSheetUnit(disposeTimeout),
setDarkMode: (darkMode) => this._setDarkMode(darkMode),
loadDefaultDoc: (loadTimeout) => this._loadDefaultDoc(loadTimeout),
loadDocLayoutFixture: (documentFlavor, loadTimeout) => this._loadDocLayoutFixture(documentFlavor, loadTimeout),
disposeUniver: () => this._disposeUniver(),
};
}
@@ -110,6 +112,17 @@ export class E2EController extends Disposable {
await awaitTime(loadingTimeout);
}
private async _loadDocLayoutFixture(documentFlavor: DocumentFlavor, loadingTimeout: number = AWAIT_LOADING_TIMEOUT): Promise<void> {
if (documentFlavor !== DocumentFlavor.TRADITIONAL && documentFlavor !== DocumentFlavor.MODERN) {
throw new Error(`Unsupported Doc E2E flavor: ${documentFlavor}`);
}
const snapshot = getDefaultDocData();
snapshot.id = `e2e-doc-layout-${documentFlavor}`;
snapshot.documentStyle = { ...snapshot.documentStyle, documentFlavor };
this._univerInstanceService.createUnit(UniverInstanceType.UNIVER_DOC, snapshot);
await awaitTime(loadingTimeout);
}
private async _disposeUniver(): Promise<void> {
window.univer?.dispose();
window.univer = undefined;
+1
View File
@@ -4,6 +4,7 @@ export interface IE2EControllerAPI {
loadAndRelease(id: number, loadTimeout?: number, disposeTimeout?: number): Promise<void>;
loadDefaultSheet(loadTimeout?: number): Promise<void>;
loadDefaultDoc(loadTimeout?: number): Promise<void>;
loadDocLayoutFixture(documentFlavor: 1 | 2, loadTimeout?: number): Promise<void>;
loadDemoSheet(loadTimeout?: number): Promise<void>;
loadMergeCellSheet(loadTimeout?: number): Promise<void>;
loadDefaultStyleSheet(loadTimeout?: number): Promise<void>;
@@ -18,3 +18,43 @@ test('diff default doc content', async ({ page }) => {
await expect(page).toHaveScreenshot(generateSnapshotName('default-doc'), { maxDiffPixels: 100 });
expect(errored).toBeFalsy();
});
test.describe('Doc layout flavors', () => {
for (const fixture of [
{ flavor: 1 as const, name: 'traditional' },
{ flavor: 2 as const, name: 'modern' },
]) {
test(`${fixture.name} renders the representative layout fixture`, async ({ page }) => {
const errors: Error[] = [];
page.on('pageerror', (error) => errors.push(error));
await page.goto('http://localhost:3000/docs/');
await page.waitForTimeout(2_000);
await page.evaluate((flavor) => window.E2EControllerAPI.loadDocLayoutFixture(flavor), fixture.flavor);
await expect.poll(
() => page.evaluate(() => window.univerAPI?.getActiveDocument()?.getDocumentFlavor()),
{ timeout: 10_000 }
).toBe(fixture.flavor);
await expect.poll(async () => {
const screenshot = await page.screenshot();
return page.evaluate(async (base64) => {
const blob = await (await fetch(`data:image/png;base64,${base64}`)).blob();
const bitmap = await createImageBitmap(blob);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const context = canvas.getContext('2d', { willReadFrequently: true });
if (!context) return 0;
context.drawImage(bitmap, 0, 0);
const pixels = context.getImageData(0, 0, bitmap.width, bitmap.height).data;
let ink = 0;
for (let index = 0; index < pixels.length; index += 16) {
const luminance = pixels[index] * 0.2126 + pixels[index + 1] * 0.7152 + pixels[index + 2] * 0.0722;
if (pixels[index + 3] > 180 && luminance < 180) ink++;
}
return ink;
}, screenshot.toString('base64'));
}, { timeout: 10_000 }).toBeGreaterThan(1_000);
expect(errors.map((error) => error.stack ?? error.message)).toEqual([]);
});
}
});
@@ -685,6 +685,10 @@ export interface IBullet {
listType: string; // listType orderList or bulletList etc.
listId: string; // listId
nestingLevel: number; // nestingLevel
startNumber?: number; // zero-based start number for a restarted ordered-list sequence
image?: {
source: string;
}; // Image used as the list marker.
textStyle?: ITextStyle; // textStyle
}
@@ -827,12 +831,18 @@ export interface IDocTextFill {
};
}
export interface IDocTextOutline {
color?: string;
width?: number;
}
export interface ITextStyle extends IStyleBase {
// bo?: BaselineOffset; // BaselineOffset, sup, sub
sc?: number; // spacing
sc?: number; // character spacing in points
pos?: number; // position
sa?: number; // scale
textFill?: IDocTextFill;
textOutline?: IDocTextOutline;
/**
* DrawingML-style glow around the rendered glyphs.
*
@@ -857,6 +867,12 @@ export interface IIndentStart {
indentEnd?: INumberUnit; // indentEnd
}
/**
* Vertical alignment of text runs inside a paragraph line box.
* This is the normalized form of DrawingML `fontAlgn`.
*/
export type ParagraphFontAlign = 'auto' | 'top' | 'center' | 'baseline' | 'bottom';
/**
* Properties of paragraph style
*/
@@ -874,6 +890,12 @@ export type IDocumentDefaultParagraphStyle = Omit<IParagraphStyle, 'headingId' |
export interface IParagraphProperties extends IIndentStart {
headingId?: string; // headingId
namedStyleType?: NamedStyleType; // namedStyleType
defaultTabStop?: number; // Distance between automatic tab stops for this paragraph.
/** Whether East Asian kinsoku line-breaking rules apply to this paragraph. */
eastAsianLineBreak?: BooleanNumber;
/** Whether punctuation may hang outside the paragraph text bounds. */
hangingPunctuation?: BooleanNumber;
fontAlign?: ParagraphFontAlign;
horizontalAlign?: HorizontalAlign; // Horizontal alignment
lineSpacing?: number; // lineSpacing 17.3.1.33 spacing (Spacing Between Lines and Above/Below Paragraph)
direction?: TextDirection; // direction
@@ -198,6 +198,38 @@ describe('drawing group', () => {
expect(child.getRealBound().width).toBe(100);
});
it('maps a rotated child through an anisotropically resized drawing group', () => {
const drawingGroup = new DrawingGroupObject('rotated-child-group');
drawingGroup.transformByState({
left: 10,
top: 20,
width: 200,
height: 100,
});
drawingGroup.setBaseBound({
left: 0,
top: 0,
width: 100,
height: 200,
});
const child = new Rect('rotated-child', {
left: -50,
top: 50,
width: 200,
height: 100,
angle: 90,
fill: '#333333',
});
drawingGroup.addObject(child);
const bound = child.getRealBound();
expect(bound.left).toBeCloseTo(-50, 6);
expect(bound.top).toBeCloseTo(-100, 6);
expect(bound.width).toBeCloseTo(100, 6);
expect(bound.height).toBeCloseTo(200, 6);
});
it('covers group object management, transform recalculation and dispose flow', () => {
const sceneChild = new Rect('scene-child', {
left: 5,
+16 -6
View File
@@ -24,7 +24,7 @@ import type { Engine } from './engine';
import type { Layer } from './layer';
import type { Scene } from './scene';
import { Disposable, EventSubject } from '@univerjs/core';
import { getRenderTransformBaseOnParentBound } from './basics';
import { getRenderTransformBaseOnParentBound, getRotatedBoundInGroup } from './basics';
import { CURSOR_TYPE, RENDER_CLASS_TYPE } from './basics/const';
import { TRANSFORM_CHANGE_OBSERVABLE_TYPE } from './basics/interfaces';
import { generateRandomKey, toPx } from './basics/tools';
@@ -642,12 +642,22 @@ export abstract class BaseObject extends Disposable {
width: parentRealBound.width || 0,
height: parentRealBound.height || 0,
};
const realBound = getRenderTransformBaseOnParentBound(baseBound, parentBound, { width: realWidth, height: realHeight, left: realLeft, top: realTop });
const rotatedBound = getRotatedBoundInGroup(
{ width: realWidth, height: realHeight, left: realLeft, top: realTop },
this.angle
);
const mappedRotatedBound = getRenderTransformBaseOnParentBound(baseBound, parentBound, rotatedBound);
const normalizedAngle = ((this.angle % 360) + 360) % 360;
const swapsAxes =
(normalizedAngle >= 45 && normalizedAngle < 135) ||
(normalizedAngle >= 225 && normalizedAngle < 315);
const mappedCenterX = mappedRotatedBound.left + mappedRotatedBound.width / 2;
const mappedCenterY = mappedRotatedBound.top + mappedRotatedBound.height / 2;
realWidth = realBound.width;
realHeight = realBound.height;
realLeft = realBound.left - parentBound.left - parentBound.width / 2;
realTop = realBound.top - parentBound.top - parentBound.height / 2;
realWidth = swapsAxes ? mappedRotatedBound.height : mappedRotatedBound.width;
realHeight = swapsAxes ? mappedRotatedBound.width : mappedRotatedBound.height;
realLeft = mappedCenterX - realWidth / 2 - parentBound.left - parentBound.width / 2;
realTop = mappedCenterY - realHeight / 2 - parentBound.top - parentBound.height / 2;
// const isParentFlipX = this.parent?.flipX;
// const isParentFlipY = this.parent?.flipY;
@@ -177,6 +177,14 @@ describe('tools extra', () => {
expect(fontStack.fontFamily).toBe('"SF Mono", "Cascadia Code", Consolas, monospace');
expect(fontStack.fontString).toContain('"SF Mono", "Cascadia Code", Consolas, monospace');
const themeFont = getFontStyleString({
bl: 1,
fs: 15,
ff: '+mj-lt',
} as any);
expect(themeFont.fontFamily).toBe('"+mj-lt"');
expect(themeFont.fontCache).toBe('normal bold 15pt "+mj-lt"');
const fractionalSize = getFontStyleString({
fs: 10.0125,
ff: 'Microsoft YaHei',
@@ -266,6 +266,7 @@ export interface IDocumentSkeletonGlyph {
url?: string; // image url
featureId?: string; // support interaction for feature ,eg. hyperLine person
drawingId?: string; // drawing.drawingId
fauxBoldStrokeWidth?: number;
}
export interface IDocumentSkeletonBullet {
@@ -274,10 +275,14 @@ export interface IDocumentSkeletonBullet {
ts: ITextStyle; // text style
fontStyle?: IDocumentSkeletonFontStyle; // fontStyle converted from ITextStyle to canvas font
startIndexItem: number; // startIndexItem, list start index
startNumber?: number; // zero-based start number retained across a restarted sequence
// bBox: IDocumentSkeletonBoundingBox; // bBox text position information
nestingLevel?: INestingLevel;
bulletAlign?: BulletAlignment;
bulletType?: boolean; // bulletType false unordered, true ordered;
compactSpacing?: boolean;
preserveTextLineHeight?: boolean;
imageSource?: string;
paragraphProperties?: IParagraphProperties;
// bp: number; // bulletPosition distance from list to page edge
// ti: number; // textIndent distance from content to list, take Max(textIndent, followWith+)
@@ -131,6 +131,15 @@ export interface IParagraphConfig {
paragraphIndex: number;
documentCompatibilityPolicy?: IDocumentCompatibilityPolicy;
useWordStyleLineHeight?: boolean;
usePptxFontSizeLineHeight?: boolean;
usePptxNominalFontLineHeight?: boolean;
usePptxCompatibleLineSpacing?: boolean;
usePptxPercentageLineSpacing?: boolean;
pptxPercentageFontSize?: number;
usePptxNormAutofitLineHeight?: boolean;
pptxEmptyParagraphFontSize?: number;
pptxHasExplicitEndParaFontSize?: boolean;
sumPptxParagraphSpacing?: boolean;
docxFallbackAnchorLeft?: IParagraphStyle['indentStart'];
paragraphNonInlineSkeDrawings?: Map<string, IDocumentSkeletonDrawing>;
paragraphInlineSkeDrawings?: Map<string, IDocumentSkeletonDrawing>;
+4 -4
View File
@@ -331,11 +331,11 @@ function normalizeFontFamily(fontFamily: Nullable<string>, defaultFont: string):
return fontFamily
.split(',')
.map((item) => {
const family = item.trim().replace(/^['"]|['"]$/g, '');
return family.includes(' ') ? `"${family}"` : family;
})
.map((item) => item.trim().replace(/^['"]|['"]$/g, ''))
.filter(Boolean)
.map((family) => /^[\p{L}_-][\p{L}\p{N}_-]*$/u.test(family)
? family
: `"${family.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`)
.join(', ');
}
@@ -205,6 +205,7 @@ describe('docs layout tools extra', () => {
lineSpacing: 0,
spacingRule: 0,
snapToGrid: BooleanNumber.TRUE,
defaultTabStop: 17,
},
};
@@ -215,7 +216,7 @@ describe('docs layout tools extra', () => {
const charCfg = getCharSpaceConfig(sectionBreakConfig as any, paragraphConfig as any);
expect(charCfg).toEqual(expect.objectContaining({
charSpace: 2,
defaultTabStop: 5,
defaultTabStop: 17,
documentFontSize: 11,
}));
});
@@ -95,6 +95,20 @@ describe('bullet', () => {
expect(result!.bulletType).toBe(true);
});
it.each([
[0, '一、'],
[9, '十、'],
[10, '十一、'],
[100, '一百零一、'],
])('generates Chinese counting symbol %s', (startNumber, expected) => {
const bullet = createBullet();
const lists = createLists([
createNestingLevel({ glyphFormat: '%1、', glyphType: ListGlyphType.CHINESE_COUNTING, startNumber }),
]);
const result = dealWithBullet(bullet, lists as unknown as ILists);
expect(result?.symbol).toBe(expected);
});
it('uses glyphSymbol directly for unordered list', () => {
const bullet = createBullet();
const lists = createLists([
@@ -106,6 +120,42 @@ describe('bullet', () => {
expect(result!.bulletType).toBe(false);
});
it('maps a legacy Wingdings character code to its Unicode visual equivalent', () => {
const bullet = createBullet({ textStyle: { ff: 'Wingdings' } });
const lists = createLists([
createNestingLevel({ glyphSymbol: '\u00A7' }),
]);
const result = dealWithBullet(bullet, lists as unknown as ILists);
expect(result).toBeDefined();
expect(result!.symbol).toBe('\u25AA');
expect(result!.ts.ff).toBe('Wingdings');
});
it('maps the Wingdings arrow used by PowerPoint bullet lists', () => {
const bullet = createBullet({ textStyle: { ff: 'Wingdings' } });
const lists = createLists([
createNestingLevel({ glyphSymbol: '\u00D8' }),
]);
const result = dealWithBullet(bullet, lists as unknown as ILists);
expect(result?.symbol).toBe('\u27A2');
});
it('preserves explicitly requested compact spacing', () => {
const bullet = createBullet();
const lists = createLists([createNestingLevel({ glyphSymbol: '\u2022' })]);
const result = dealWithBullet(
bullet,
lists as unknown as ILists,
undefined,
undefined,
true,
{ fs: 15 }
);
expect(result!.compactSpacing).toBe(true);
expect(result!.preserveTextLineHeight).toBe(true);
expect(result!.ts.fs).toBe(15);
});
it('handles multi-level glyphFormat', () => {
const bullet = createBullet({ nestingLevel: 1 });
const lists = createLists([
@@ -132,6 +182,23 @@ describe('bullet', () => {
expect(result!.ts.ff).toBe('Arial');
});
it('inherits paragraph text style when the bullet has no explicit style', () => {
const bullet = createBullet();
const lists = createLists([createNestingLevel({ glyphSymbol: '\u2022' })]);
const result = dealWithBullet(
bullet,
lists as unknown as ILists,
undefined,
undefined,
true,
{ ff: 'Arial', fs: 24 }
);
expect(result!.ts.ff).toBe('Arial');
expect(result!.ts.fs).toBe(24);
});
it('includes paragraphProperties from nestingLevel', () => {
const bullet = createBullet();
const lists = createLists([
@@ -159,6 +226,28 @@ describe('bullet', () => {
expect(result).toBeDefined();
expect(result?.startIndexItem).toBe(6);
});
it('restarts an ordered sequence without changing the parent level counter', () => {
const lists = createLists([
createNestingLevel({ glyphFormat: '%1.', glyphType: ListGlyphType.DECIMAL, startNumber: 0 }),
createNestingLevel({ glyphFormat: '%2.', glyphType: ListGlyphType.LOWER_LETTER, startNumber: 0 }),
]);
const listLevelAncestors: Array<Record<string, unknown> | null> = [
{ startIndexItem: 3, startNumber: 0, symbol: '2.' },
{ startIndexItem: 3, startNumber: 0, symbol: 'b.' },
];
const restarted = dealWithBullet(
createBullet({ nestingLevel: 1, startNumber: 0 }),
lists as unknown as ILists,
listLevelAncestors as unknown as Parameters<typeof dealWithBullet>[2]
);
expect(restarted?.symbol).toBe('a.');
expect(restarted?.startIndexItem).toBe(2);
expect(restarted?.startNumber).toBe(0);
expect(listLevelAncestors[0]?.startIndexItem).toBe(3);
});
});
describe('getDefaultBulletSke', () => {
@@ -135,6 +135,7 @@ describe('layout-ruler', () => {
expect(result.length).toBe(1);
expect(result[0].sections.length).toBeGreaterThan(0);
expect(result[0].sections[0].columns[0].lines[0].divides[0].glyphGroup[0].width).toBe(21);
});
it('uses trailing CJK punctuation shrinkability when deciding line overflow', () => {
@@ -147,6 +148,15 @@ describe('layout-ruler', () => {
expect(__testing.isGlyphGroupBeyondDivideWidth([text, punctuation], 85, 100)).toBe(true);
});
it('allows explicit hanging punctuation to extend beyond the line end', () => {
const text = createGlyph('字', 10);
const punctuation = createGlyph('。', 10);
punctuation.adjustability.shrinkability = [0, 0];
expect(__testing.isGlyphGroupBeyondDivideWidth([text, punctuation], 85, 100)).toBe(true);
expect(__testing.isGlyphGroupBeyondDivideWidth([text, punctuation], 85, 100, true)).toBe(false);
});
it('keeps direct paragraph indents before bullet list defaults', () => {
const { ctx, paragraphNode, sectionBreakConfig, curPage } = createParagraphLayoutTestBed('Item');
const shapedTextList = shaping(ctx, paragraphNode.content!, ctx.viewModel, paragraphNode, sectionBreakConfig);
@@ -182,6 +192,7 @@ describe('layout-ruler', () => {
expect(paragraphConfig.paragraphStyle?.indentStart).toEqual({ v: 12 });
expect(paragraphConfig.paragraphStyle?.hanging).toEqual({ v: 12 });
expect(curPage.sections[0].columns[0].lines[0].divides[0].glyphGroup[0].width).toBe(12);
});
it('lays out first shaped text without bullet', () => {
@@ -44,6 +44,9 @@ function generateOrderedSymbol(startIndex: number, startNumber: number, glyphTyp
if (glyphType === ListGlyphType.LOWER_ROMAN) {
return roman(startIndex, startNumber);
}
if (glyphType === ListGlyphType.CHINESE_COUNTING) {
return chineseCounting(startIndex, startNumber);
}
return decimal(startIndex, startNumber);
}
@@ -84,6 +87,35 @@ function roman(startIndex: number, startNumber: number) {
return _convertRoman(startIndex + startNumber, false);
}
function chineseCounting(startIndex: number, startNumber: number) {
const value = startIndex + startNumber;
if (value <= 0 || value >= 10000) {
return value.toString();
}
const digits = '零一二三四五六七八九';
const units = ['', '十', '百', '千'];
let result = '';
let pendingZero = false;
for (let place = 3; place >= 0; place--) {
const divisor = 10 ** place;
const digit = Math.floor(value / divisor) % 10;
if (digit === 0) {
pendingZero ||= result.length > 0 && value % divisor > 0;
continue;
}
if (pendingZero) {
result += digits[0];
pendingZero = false;
}
if (!(digit === 1 && place === 1 && result.length === 0)) {
result += digits[digit];
}
result += units[place];
}
return result;
}
function _convertRoman(num: number, uppercase = false) {
const upperLookup = {
M: 1000,
@@ -23,13 +23,16 @@ export function dealWithBullet(
bullet?: IBullet,
lists?: ILists,
listLevelAncestors?: Array<Nullable<IDocumentSkeletonBullet>>,
localeService?: LocaleService
localeService?: LocaleService,
compactSpacing = false,
paragraphTextStyle?: ITextStyle
): IDocumentSkeletonBullet | undefined {
if (!bullet || !lists) {
return;
}
const { listId, listType, nestingLevel = 0, textStyle } = bullet;
const { listId, listType, nestingLevel = 0, startNumber, image, textStyle } = bullet;
const preserveTextLineHeight = compactSpacing;
const list = lists[listType];
@@ -48,8 +51,12 @@ export function dealWithBullet(
nestingLevel,
list.nestingLevel,
listLevelAncestors,
textStyle,
localeService
startNumber,
{ ...paragraphTextStyle, ...textStyle },
localeService,
compactSpacing,
preserveTextLineHeight,
image?.source
);
return bulletSke;
}
@@ -89,8 +96,12 @@ function _getBulletSke(
nestingLevel: number,
nestings: INestingLevel[],
listLevelAncestors?: Array<Nullable<IDocumentSkeletonBullet>>,
paragraphStartNumber?: number,
textStyleConfig?: ITextStyle,
_localeService?: LocaleService
_localeService?: LocaleService,
compactSpacing = false,
preserveTextLineHeight = false,
imageSource?: string
): IDocumentSkeletonBullet {
const nesting = nestings[nestingLevel];
const {
@@ -103,20 +114,31 @@ function _getBulletSke(
} = nesting;
const textStyle = { ...textStyleConfig, ...textStyleFirst };
const fontStyle = getFontStyleString(textStyle); // Get font style in canvas.font format
const previousAtLevel = listLevelAncestors?.[nestingLevel];
const startIndex = paragraphStartNumber === undefined
? previousAtLevel?.startIndexItem ?? 1
: 1;
const effectiveStartNumber = paragraphStartNumber ?? previousAtLevel?.startNumber ?? nesting.startNumber;
let symbolContent: string;
if (glyphSymbol) {
// Unordered list uses directly
symbolContent = glyphSymbol;
symbolContent = normalizeLegacySymbolFontGlyph(glyphSymbol, textStyle.ff);
} else {
// Ordered list
symbolContent = __generateOrderedListSymbol(glyphFormat, nestingLevel, nestings, listLevelAncestors); // Ordered list processing
symbolContent = __generateOrderedListSymbol(
glyphFormat,
nestingLevel,
nestings,
listLevelAncestors,
startIndex,
effectiveStartNumber
); // Ordered list processing
}
// const bBox = FontCache.getTextSize(symbolContent, fontStyle);
const startIndex = listLevelAncestors?.[nestingLevel]?.startIndexItem ?? 1;
return {
listId,
@@ -124,19 +146,51 @@ function _getBulletSke(
ts: textStyle, // text style
fontStyle, //
startIndexItem: startIndex + 1,
startNumber: effectiveStartNumber,
// bBox,
nestingLevel: nesting,
bulletAlign: bulletAlignment,
bulletType: glyphSymbol ? false : !!glyphType, // Default is unordered list, only ordered if glyphSymbol is empty and glyphType is not empty
compactSpacing,
preserveTextLineHeight,
imageSource,
paragraphProperties: nesting.paragraphProperties,
};
}
const LEGACY_SYMBOL_GLYPH_EQUIVALENTS: Record<string, Record<number, string>> = {
wingdings: {
0xA7: '\u25AA',
0xD8: '\u27A2',
},
};
function normalizeLegacySymbolFontGlyph(symbol: string, fontFamily?: Nullable<string>): string {
const primaryFontFamily = fontFamily
?.split(',')[0]
?.trim()
.replace(/^['"]|['"]$/g, '')
.toLowerCase();
const equivalents = primaryFontFamily
? LEGACY_SYMBOL_GLYPH_EQUIVALENTS[primaryFontFamily]
: undefined;
if (!equivalents) {
return symbol;
}
return Array.from(symbol, (character) => {
const codePoint = character.codePointAt(0);
return codePoint === undefined ? character : equivalents[codePoint] ?? character;
}).join('');
}
function __generateOrderedListSymbol(
glyphFormat: string,
nestingLevel: number,
nestings: INestingLevel[],
listLevelAncestors?: Array<Nullable<IDocumentSkeletonBullet>>
listLevelAncestors: Array<Nullable<IDocumentSkeletonBullet>> | undefined,
currentStartIndex: number,
currentStartNumber: number
) {
// const indexNumber = startNumber + startIndex;
// parse <prefix>%[nestingLevelMinusOne]<suffix>, return symbolContent
@@ -155,13 +209,17 @@ function __generateOrderedListSymbol(
const levelAndSuffixPre = glyphFormatSplit[i];
const { level, suffix } = ___getLevelAndSuffix(levelAndSuffixPre);
let startIndexItem = listLevelAncestors?.[level]?.startIndexItem || 1;
const ancestor = listLevelAncestors?.[level];
let startIndexItem = level === nestingLevel ? currentStartIndex : ancestor?.startIndexItem || 1;
if (level !== nestingLevel && listLevelAncestors?.[level] !== null) {
if (level !== nestingLevel && ancestor !== null) {
startIndexItem -= 1;
}
const singleSymbol = ___getSymbolByBesting(startIndexItem, nestings[level]);
const startNumber = level === nestingLevel
? currentStartNumber
: ancestor?.startNumber ?? nestings[level].startNumber;
const singleSymbol = ___getSymbolByBesting(startIndexItem, nestings[level], startNumber);
// console.log(
// '___getSymbolByBesting',
// singleSymbol,
@@ -178,8 +236,8 @@ function __generateOrderedListSymbol(
return resultSymbol.join('');
}
function ___getSymbolByBesting(startIndex: number = 1, nesting: INestingLevel) {
const { startNumber, glyphType, glyphSymbol } = nesting;
function ___getSymbolByBesting(startIndex: number = 1, nesting: INestingLevel, startNumber = nesting.startNumber) {
const { glyphType, glyphSymbol } = nesting;
if (glyphSymbol) {
// Unordered list uses directly
@@ -44,6 +44,7 @@ import {
WrapStrategy,
} from '@univerjs/core';
import { GlyphType, LineType } from '../../../../../basics/i-document-skeleton-cached';
import { isCjkLeftAlignedPunctuation } from '../../../../../basics/tools';
import { getDocsCustomBlockRenderViewport } from '../../../custom-block-render-viewport';
import { BreakPointType } from '../../line-breaker/break';
import { addGlyphToDivide, createSkeletonBulletGlyph } from '../../model/glyph';
@@ -99,12 +100,17 @@ function isBeyondDivideWidth(width: number, divideWidth: number) {
function isGlyphGroupBeyondDivideWidth(
glyphGroup: IDocumentSkeletonGlyph[],
offsetLeft: number,
divideWidth: number
divideWidth: number,
hangingPunctuation = false
) {
const width = __getGlyphGroupWidth(glyphGroup);
const trailingShrinkability = glyphGroup[glyphGroup.length - 1]?.adjustability?.shrinkability?.[1] ?? 0;
const trailingGlyph = glyphGroup[glyphGroup.length - 1];
const trailingShrinkability = trailingGlyph?.adjustability?.shrinkability?.[1] ?? 0;
const trailingHangingWidth = hangingPunctuation && trailingGlyph && isCjkLeftAlignedPunctuation(trailingGlyph.content)
? trailingGlyph.width
: 0;
return isBeyondDivideWidth(offsetLeft + width - trailingShrinkability, divideWidth);
return isBeyondDivideWidth(offsetLeft + width - Math.max(trailingShrinkability, trailingHangingWidth), divideWidth);
}
export function layoutParagraph(
@@ -138,6 +144,11 @@ export function layoutParagraph(
...paragraphConfig.paragraphStyle,
};
const hangingWidth = getNumberUnitValue(paragraphConfig.paragraphStyle.hanging, charSpaceApply);
if (hangingWidth > 0) {
bulletGlyph.width = hangingWidth;
}
_lineOperator(ctx, [bulletGlyph, ...glyphGroup], pages, sectionBreakConfig, paragraphConfig, isParagraphFirstShapedText, breakPointType);
} else {
_lineOperator(ctx, glyphGroup, pages, sectionBreakConfig, paragraphConfig, isParagraphFirstShapedText, breakPointType);
@@ -267,7 +278,8 @@ function _divideOperator(
const lastLeft = lastGlyph?.left || 0;
const preOffsetLeft = lastWidth + lastLeft;
const { hyphenationZone } = sectionBreakConfig;
if (isGlyphGroupBeyondDivideWidth(glyphGroup, preOffsetLeft, divide.width)) {
const hangingPunctuation = paragraphConfig.paragraphStyle?.hangingPunctuation === BooleanNumber.TRUE;
if (isGlyphGroupBeyondDivideWidth(glyphGroup, preOffsetLeft, divide.width, hangingPunctuation)) {
if (
divide?.glyphGroup.length === 0 &&
glyphGroup.length > 0 &&
@@ -336,7 +348,7 @@ function _divideOperator(
while (glyphGroup.length) {
sliceGlyphGroup.push(glyphGroup.shift()!);
if (isGlyphGroupBeyondDivideWidth(sliceGlyphGroup, 0, divide.width)) {
if (isGlyphGroupBeyondDivideWidth(sliceGlyphGroup, 0, divide.width, hangingPunctuation)) {
// To avoid infinity loop when width is less than one char's width.
if (sliceGlyphGroup.length > 1) { // || (sliceGlyphGroup.length > 0 && sliceGlyphGroup[sliceGlyphGroup.length - 1].drawingId)) {
glyphGroup.unshift(sliceGlyphGroup.pop()!);
@@ -38,6 +38,7 @@ import { LineBreakerHyphenEnhancer } from '../../line-breaker/enhancers/hyphen-e
import { LineBreakerLinkEnhancer } from '../../line-breaker/enhancers/link-enhancer';
import { LineBreakerWholeEntityEnhancer } from '../../line-breaker/enhancers/whole-entity-enhancer';
import { customBlockLineBreakExtension } from '../../line-breaker/extensions/custom-block-linebreak-extension';
import { eastAsianQuoteLineBreakExtension } from '../../line-breaker/extensions/east-asian-quote-linebreak-extension';
import { tabLineBreakExtension } from '../../line-breaker/extensions/tab-linebreak-extension';
import {
createSkeletonCustomBlockGlyph,
@@ -228,6 +229,9 @@ export function shaping(
// Add custom extension for linebreak.
tabLineBreakExtension(lineBreaker);
customBlockLineBreakExtension(lineBreaker);
if (cjk.hasCJKText(content)) {
eastAsianQuoteLineBreakExtension(lineBreaker);
}
let breaker: IBreakPoints = new LineBreakerLinkEnhancer(lineBreaker);
@@ -17,6 +17,7 @@
import type { IDocumentData, ITextRun, ITextStyle } from '@univerjs/core';
import { getFontStyleString } from '../../../basics/tools';
import { LineBreaker } from './line-breaker';
import { BreakPointType } from './line-breaker/break';
import { FontCache } from './shaping-engine/font-cache';
function splitDocumentNoWrapMeasureLines(text: string): string[] {
@@ -220,6 +221,65 @@ export function measureDocumentNoWrapTextWidth(documentData: IDocumentData | nul
);
}
/**
* Measures the widest line after applying the docs Unicode break policy
* within a fixed-width host.
*/
export function measureDocumentWrappedTextWidth(
documentData: IDocumentData | null | undefined,
maxLineWidth: number
): number {
const dataStream = documentData?.body?.dataStream ?? '';
if (!documentData || !dataStream || !Number.isFinite(maxLineWidth) || maxLineWidth <= 0) {
return 0;
}
const breaker = new LineBreaker(dataStream);
let lineStart = 0;
let lastFittingEnd = 0;
let widestLine = 0;
let breakPoint = breaker.nextBreakPoint();
while (breakPoint) {
const candidateEnd = breakPoint.position;
const candidateWidth = measureDocumentNoWrapTextRangeWidth(documentData, lineStart, candidateEnd);
if (candidateWidth <= maxLineWidth) {
lastFittingEnd = candidateEnd;
if (breakPoint.type === BreakPointType.Mandatory) {
widestLine = Math.max(widestLine, candidateWidth);
lineStart = candidateEnd;
lastFittingEnd = candidateEnd;
}
breakPoint = breaker.nextBreakPoint();
continue;
}
if (lastFittingEnd > lineStart) {
widestLine = Math.max(
widestLine,
measureDocumentNoWrapTextRangeWidth(documentData, lineStart, lastFittingEnd)
);
lineStart = lastFittingEnd;
continue;
}
widestLine = Math.max(widestLine, maxLineWidth);
lineStart = candidateEnd;
lastFittingEnd = candidateEnd;
breakPoint = breaker.nextBreakPoint();
}
if (lineStart < dataStream.length) {
widestLine = Math.max(
widestLine,
Math.min(maxLineWidth, measureDocumentNoWrapTextRangeWidth(documentData, lineStart, dataStream.length))
);
}
return widestLine;
}
/**
* Measures the widest segment that docs line breaking keeps together. This is
* useful when a host may wrap normally but still needs enough width to avoid
@@ -18,6 +18,7 @@
import fs from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { eastAsianQuoteLineBreakExtension } from '../extensions/east-asian-quote-linebreak-extension';
import { tabLineBreakExtension } from '../extensions/tab-linebreak-extension';
import { LineBreaker } from '../line-breaker';
@@ -77,6 +78,22 @@ describe('unicode line break tests', () => {
});
describe('line break extensions tests', () => {
it('should allow an East Asian line to break before a smart opening quote', () => {
const data = '力” 、“年度';
const breaker = new LineBreaker(data);
eastAsianQuoteLineBreakExtension(breaker);
const breaks: string[] = [];
let last = 0;
let bk;
while ((bk = breaker.nextBreakPoint())) {
breaks.push(data.slice(last, bk.position));
last = bk.position;
}
expect(breaks).toStrictEqual(['力” 、', '“年', '度']);
});
it('should break before tab in Chinese', () => {
const data = '中\t国';
const breaker = new LineBreaker(data);
@@ -0,0 +1,25 @@
/**
* 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 { LineBreaker } from '../line-breaker';
const EAST_ASIAN_OPENING_QUOTES = new Set([0x2018, 0x201C]);
export function eastAsianQuoteLineBreakExtension(breaker: LineBreaker) {
breaker.addRule('break_before_east_asian_opening_quote', (codePoint) => {
return EAST_ASIAN_OPENING_QUOTES.has(codePoint);
});
}
@@ -36,6 +36,15 @@ describe('custom range style', () => {
});
});
it('preserves the text run color when requested by the custom range', () => {
expect(getCustomRangeStyle({
rangeType: CustomRangeType.HYPERLINK,
properties: { textColorMode: 'text' },
} as never)).toEqual({
ul: { s: BooleanNumber.TRUE },
});
});
it('does not style unsupported custom range types', () => {
expect(getCustomRangeStyle({ rangeType: CustomRangeType.COMMENT } as never)).toBeNull();
});
@@ -23,9 +23,10 @@ export function getCustomRangeStyle(customRange: ICustomRangeForInterceptor): Nu
customRange.rangeType === CustomRangeType.MENTION ||
customRange.rangeType === CustomRangeType.CUSTOM
) {
const preserveTextColor = customRange.properties?.textColorMode === 'text';
return {
...(customRange.active ?? true) ? { ul: { s: BooleanNumber.TRUE } } : null,
cl: { rgb: '#274fee' },
...preserveTextColor ? null : { cl: { rgb: '#274fee' } },
};
}
@@ -341,12 +341,15 @@ export function getCharSpaceConfig(sectionBreakConfig: ISectionBreakConfig, para
const { fs: documentFontSize = DEFAULT_DOCUMENT_FONTSIZE } = documentTextStyle;
const { snapToGrid = BooleanNumber.TRUE } = paragraphStyle;
const {
snapToGrid = BooleanNumber.TRUE,
defaultTabStop: paragraphDefaultTabStop,
} = paragraphStyle;
return {
charSpace,
documentFontSize,
defaultTabStop,
defaultTabStop: paragraphDefaultTabStop ?? defaultTabStop,
gridType,
snapToGrid,
};
+1 -1
View File
@@ -44,7 +44,7 @@ export class DrawingGroupObject extends Group {
height: 0,
};
private _outerShadow?: IDrawingGroupShadow;
protected _outerShadow?: IDrawingGroupShadow;
private _glow?: IGlowEffect;
+1
View File
@@ -39,6 +39,7 @@ export {
measureDocumentNoWrapTextRangeWidth,
measureDocumentNoWrapTextWidth,
measureDocumentUnbreakableTextWidth,
measureDocumentWrappedTextWidth,
} from './components/docs/layout/doc-no-wrap-measure';
export * from './components/docs/layout/doc-simple-skeleton';
export { DocumentSkeleton } from './components/docs/layout/doc-skeleton';
@@ -86,6 +86,29 @@ describe('image extra', () => {
expect(ctx.drawImage).toHaveBeenCalled();
});
it('scales srcRect offsets with a group-resized render bound', () => {
const native = createNativeImage(100, 60);
const image = new Image('group-cropped-image', {
image: native,
left: 20,
top: 10,
width: 100,
height: 60,
srcRect: { left: 10, top: 12, right: 14, bottom: 16 },
});
vi.spyOn(image, 'getRealBound').mockReturnValue({
left: 0,
top: 0,
width: 50,
height: 30,
});
const ctx = createCtxMock();
image.render(ctx);
expect(ctx.drawImage).toHaveBeenCalledWith(native, -30, -21, 62, 44);
});
it('supports source switching, reset size and hit testing', () => {
const image = new Image('img2', {
image: createNativeImage(90, 50),
+14 -4
View File
@@ -403,9 +403,11 @@ export class Image extends Shape<IImageProps> {
const drawHeight = clipBounds.height;
if (!this._renderByCropper && this.srcRect) {
const { left = 0, top = 0, right = 0, bottom = 0 } = this.srcRect;
// Scale srcRect offsets proportionally to the actual clip bounds
const scaleW = drawWidth / w;
const scaleH = drawHeight / h;
// srcRect offsets live in the image's original frame. Scale
// them with the rendered frame (not just the shape clip), so
// images resized by a drawing group keep the same crop.
const scaleW = this.width > 0 ? drawWidth / this.width : 1;
const scaleH = this.height > 0 ? drawHeight / this.height : 1;
ctx.drawImage(
this._native,
drawLeft - left * scaleW,
@@ -424,10 +426,18 @@ export class Image extends Shape<IImageProps> {
if (!this._renderByCropper && this.srcRect) {
const { left = 0, top = 0, right = 0, bottom = 0 } = this.srcRect;
const scaleW = this.width > 0 ? w / this.width : 1;
const scaleH = this.height > 0 ? h / this.height : 1;
ctx.beginPath();
ctx.rect(-w / 2, -h / 2, w, h);
ctx.clip();
ctx.drawImage(this._native, -left - w / 2, -top - h / 2, w + right + left, h + bottom + top);
ctx.drawImage(
this._native,
-left * scaleW - w / 2,
-top * scaleH - h / 2,
w + (right + left) * scaleW,
h + (bottom + top) * scaleH
);
} else {
ctx.drawImage(this._native, -w / 2, -h / 2, w, h);
}