diff --git a/common/debugger/src/controllers/e2e/e2e.controller.ts b/common/debugger/src/controllers/e2e/e2e.controller.ts index 4ddc7d77c8..945fb7a026 100644 --- a/common/debugger/src/controllers/e2e/e2e.controller.ts +++ b/common/debugger/src/controllers/e2e/e2e.controller.ts @@ -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; loadDefaultStyleSheet(loadTimeout?: number): Promise; loadDefaultDoc(loadTimeout?: number,): Promise; + loadDocLayoutFixture(documentFlavor: DocumentFlavor, loadTimeout?: number): Promise; setDarkMode(darkMode: boolean): void; disposeUniver(): Promise; disposeCurrSheetUnit(disposeTimeout?: number): Promise; @@ -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 { + 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 { window.univer?.dispose(); window.univer = undefined; diff --git a/e2e/e2e.d.ts b/e2e/e2e.d.ts index f12a7f8a01..dda690c409 100644 --- a/e2e/e2e.d.ts +++ b/e2e/e2e.d.ts @@ -4,6 +4,7 @@ export interface IE2EControllerAPI { loadAndRelease(id: number, loadTimeout?: number, disposeTimeout?: number): Promise; loadDefaultSheet(loadTimeout?: number): Promise; loadDefaultDoc(loadTimeout?: number): Promise; + loadDocLayoutFixture(documentFlavor: 1 | 2, loadTimeout?: number): Promise; loadDemoSheet(loadTimeout?: number): Promise; loadMergeCellSheet(loadTimeout?: number): Promise; loadDefaultStyleSheet(loadTimeout?: number): Promise; diff --git a/e2e/visual-comparison/docs/docs-visual-comparison.spec.ts b/e2e/visual-comparison/docs/docs-visual-comparison.spec.ts index e0e83d632f..3e7945ee5e 100644 --- a/e2e/visual-comparison/docs/docs-visual-comparison.spec.ts +++ b/e2e/visual-comparison/docs/docs-visual-comparison.spec.ts @@ -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([]); + }); + } +}); diff --git a/packages/core/src/types/interfaces/i-document-data.ts b/packages/core/src/types/interfaces/i-document-data.ts index 32ebdcc826..b06caee201 100644 --- a/packages/core/src/types/interfaces/i-document-data.ts +++ b/packages/core/src/types/interfaces/i-document-data.ts @@ -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 { 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, diff --git a/packages/engine-render/src/base-object.ts b/packages/engine-render/src/base-object.ts index b1255f3cd8..7d8ff23963 100644 --- a/packages/engine-render/src/base-object.ts +++ b/packages/engine-render/src/base-object.ts @@ -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; diff --git a/packages/engine-render/src/basics/__tests__/tools.spec.ts b/packages/engine-render/src/basics/__tests__/tools.spec.ts index 1440df0291..0466cc80d8 100644 --- a/packages/engine-render/src/basics/__tests__/tools.spec.ts +++ b/packages/engine-render/src/basics/__tests__/tools.spec.ts @@ -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', diff --git a/packages/engine-render/src/basics/i-document-skeleton-cached.ts b/packages/engine-render/src/basics/i-document-skeleton-cached.ts index b1d97502be..7e8bc04fc2 100644 --- a/packages/engine-render/src/basics/i-document-skeleton-cached.ts +++ b/packages/engine-render/src/basics/i-document-skeleton-cached.ts @@ -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+) diff --git a/packages/engine-render/src/basics/interfaces.ts b/packages/engine-render/src/basics/interfaces.ts index b5d9738264..53f9da120a 100644 --- a/packages/engine-render/src/basics/interfaces.ts +++ b/packages/engine-render/src/basics/interfaces.ts @@ -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; paragraphInlineSkeDrawings?: Map; diff --git a/packages/engine-render/src/basics/tools.ts b/packages/engine-render/src/basics/tools.ts index 0aa2dfcab9..32f9eda3c1 100644 --- a/packages/engine-render/src/basics/tools.ts +++ b/packages/engine-render/src/basics/tools.ts @@ -331,11 +331,11 @@ function normalizeFontFamily(fontFamily: Nullable, 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(', '); } diff --git a/packages/engine-render/src/components/docs/layout/__tests__/tools.spec.ts b/packages/engine-render/src/components/docs/layout/__tests__/tools.spec.ts index 31fab0f4b8..a46b116a04 100644 --- a/packages/engine-render/src/components/docs/layout/__tests__/tools.spec.ts +++ b/packages/engine-render/src/components/docs/layout/__tests__/tools.spec.ts @@ -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, })); }); diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/bullet.spec.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/bullet.spec.ts index 8ebf0eb898..927fb6456a 100644 --- a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/bullet.spec.ts +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/bullet.spec.ts @@ -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 | 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[2] + ); + + expect(restarted?.symbol).toBe('a.'); + expect(restarted?.startIndexItem).toBe(2); + expect(restarted?.startNumber).toBe(0); + expect(listLevelAncestors[0]?.startIndexItem).toBe(3); + }); }); describe('getDefaultBulletSke', () => { diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/layout-ruler.spec.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/layout-ruler.spec.ts index 5959c4032e..d54c739cff 100644 --- a/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/layout-ruler.spec.ts +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/__tests__/layout-ruler.spec.ts @@ -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', () => { diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/bullet-ruler.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/bullet-ruler.ts index 3aea27c97c..f8993e33e5 100644 --- a/packages/engine-render/src/components/docs/layout/block/paragraph/bullet-ruler.ts +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/bullet-ruler.ts @@ -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, diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/bullet.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/bullet.ts index ab752f5bd5..2451f38d43 100644 --- a/packages/engine-render/src/components/docs/layout/block/paragraph/bullet.ts +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/bullet.ts @@ -23,13 +23,16 @@ export function dealWithBullet( bullet?: IBullet, lists?: ILists, listLevelAncestors?: Array>, - 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>, + 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> = { + wingdings: { + 0xA7: '\u25AA', + 0xD8: '\u27A2', + }, +}; + +function normalizeLegacySymbolFontGlyph(symbol: string, fontFamily?: Nullable): 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> + listLevelAncestors: Array> | undefined, + currentStartIndex: number, + currentStartNumber: number ) { // const indexNumber = startNumber + startIndex; // parse %[nestingLevelMinusOne], 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 diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/layout-ruler.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/layout-ruler.ts index 1189d76cca..81ca438a16 100644 --- a/packages/engine-render/src/components/docs/layout/block/paragraph/layout-ruler.ts +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/layout-ruler.ts @@ -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()!); diff --git a/packages/engine-render/src/components/docs/layout/block/paragraph/shaping.ts b/packages/engine-render/src/components/docs/layout/block/paragraph/shaping.ts index 212367efae..d80fcf56f4 100644 --- a/packages/engine-render/src/components/docs/layout/block/paragraph/shaping.ts +++ b/packages/engine-render/src/components/docs/layout/block/paragraph/shaping.ts @@ -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); diff --git a/packages/engine-render/src/components/docs/layout/doc-no-wrap-measure.ts b/packages/engine-render/src/components/docs/layout/doc-no-wrap-measure.ts index f5d2ddd0ea..c9b113b509 100644 --- a/packages/engine-render/src/components/docs/layout/doc-no-wrap-measure.ts +++ b/packages/engine-render/src/components/docs/layout/doc-no-wrap-measure.ts @@ -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 diff --git a/packages/engine-render/src/components/docs/layout/line-breaker/__tests__/linebreak.spec.ts b/packages/engine-render/src/components/docs/layout/line-breaker/__tests__/linebreak.spec.ts index 2002f4759f..076e45d3ac 100644 --- a/packages/engine-render/src/components/docs/layout/line-breaker/__tests__/linebreak.spec.ts +++ b/packages/engine-render/src/components/docs/layout/line-breaker/__tests__/linebreak.spec.ts @@ -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); diff --git a/packages/engine-render/src/components/docs/layout/line-breaker/extensions/east-asian-quote-linebreak-extension.ts b/packages/engine-render/src/components/docs/layout/line-breaker/extensions/east-asian-quote-linebreak-extension.ts new file mode 100644 index 0000000000..bce8cffd92 --- /dev/null +++ b/packages/engine-render/src/components/docs/layout/line-breaker/extensions/east-asian-quote-linebreak-extension.ts @@ -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); + }); +} diff --git a/packages/engine-render/src/components/docs/layout/style/__tests__/custom-range.spec.ts b/packages/engine-render/src/components/docs/layout/style/__tests__/custom-range.spec.ts index 3bc610793a..0ceb4597f3 100644 --- a/packages/engine-render/src/components/docs/layout/style/__tests__/custom-range.spec.ts +++ b/packages/engine-render/src/components/docs/layout/style/__tests__/custom-range.spec.ts @@ -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(); }); diff --git a/packages/engine-render/src/components/docs/layout/style/custom-range.ts b/packages/engine-render/src/components/docs/layout/style/custom-range.ts index 2144650e36..25ebe50850 100644 --- a/packages/engine-render/src/components/docs/layout/style/custom-range.ts +++ b/packages/engine-render/src/components/docs/layout/style/custom-range.ts @@ -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' } }, }; } diff --git a/packages/engine-render/src/components/docs/layout/tools.ts b/packages/engine-render/src/components/docs/layout/tools.ts index 7cda60d485..9d4e5a79b8 100644 --- a/packages/engine-render/src/components/docs/layout/tools.ts +++ b/packages/engine-render/src/components/docs/layout/tools.ts @@ -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, }; diff --git a/packages/engine-render/src/drawing-group.ts b/packages/engine-render/src/drawing-group.ts index 24eadc2587..fd751f9b90 100644 --- a/packages/engine-render/src/drawing-group.ts +++ b/packages/engine-render/src/drawing-group.ts @@ -44,7 +44,7 @@ export class DrawingGroupObject extends Group { height: 0, }; - private _outerShadow?: IDrawingGroupShadow; + protected _outerShadow?: IDrawingGroupShadow; private _glow?: IGlowEffect; diff --git a/packages/engine-render/src/index.ts b/packages/engine-render/src/index.ts index dad39d24e2..912228f7ec 100644 --- a/packages/engine-render/src/index.ts +++ b/packages/engine-render/src/index.ts @@ -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'; diff --git a/packages/engine-render/src/shape/__tests__/image.spec.ts b/packages/engine-render/src/shape/__tests__/image.spec.ts index 8c0d0a80c9..b130ce8479 100644 --- a/packages/engine-render/src/shape/__tests__/image.spec.ts +++ b/packages/engine-render/src/shape/__tests__/image.spec.ts @@ -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), diff --git a/packages/engine-render/src/shape/image.ts b/packages/engine-render/src/shape/image.ts index 5722f8e506..7e20dd0148 100644 --- a/packages/engine-render/src/shape/image.ts +++ b/packages/engine-render/src/shape/image.ts @@ -403,9 +403,11 @@ export class Image extends Shape { 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 { 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); }