mirror of
https://github.com/dream-num/univer.git
synced 2026-08-28 23:01:30 +08:00
fix(engine-render): use model-authoritative slide rendering (#7378)
This commit is contained in:
@@ -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;
|
||||
|
||||
Vendored
+1
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
});
|
||||
|
||||
+89
@@ -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', () => {
|
||||
|
||||
+11
@@ -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
|
||||
|
||||
+17
@@ -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);
|
||||
|
||||
+25
@@ -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,
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ export class DrawingGroupObject extends Group {
|
||||
height: 0,
|
||||
};
|
||||
|
||||
private _outerShadow?: IDrawingGroupShadow;
|
||||
protected _outerShadow?: IDrawingGroupShadow;
|
||||
|
||||
private _glow?: IGlowEffect;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user