diff --git a/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts b/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts index a61e6485886..384ebe928b8 100644 --- a/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts +++ b/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts @@ -1,24 +1,15 @@ import { addParser } from '../utils/addParser'; +import { adjustPercentileLineHeight } from '../parsers/adjustPercentileLineHeightParser'; import { getStyleMetadata } from './getStyleMetadata'; import { getStyles } from '../utils/getStyles'; +import { listLevelParser } from '../parsers/listLevelParser'; import { processWordComments } from './processWordComments'; import { processWordList } from './processWordLists'; import { removeNegativeTextIndentParser } from '../parsers/removeNegativeTextIndentParser'; import { setProcessor } from '../utils/setProcessor'; +import { wordTableParser } from '../parsers/wordTableParser'; import type { WordMetadata } from './WordMetadata'; -import type { - BeforePasteEvent, - ContentModelBlockFormat, - ContentModelListItemLevelFormat, - ContentModelTableFormat, - DomToModelContext, - ElementProcessor, - FormatParser, -} from 'roosterjs-content-model-types'; - -const PERCENTAGE_REGEX = /%/; -// Default line height in browsers according to https://developer.mozilla.org/en-US/docs/Web/CSS/line-height#normal -const DEFAULT_BROWSER_LINE_HEIGHT_PERCENTAGE = 1.2; +import type { BeforePasteEvent, ElementProcessor } from 'roosterjs-content-model-types'; /** * @internal @@ -52,39 +43,3 @@ const wordDesktopElementProcessor = ( } }; }; - -function adjustPercentileLineHeight(format: ContentModelBlockFormat, element: HTMLElement): void { - //If the line height is less than the browser default line height, line between the text is going to be too narrow - let parsedLineHeight: number; - if ( - PERCENTAGE_REGEX.test(element.style.lineHeight) && - !isNaN((parsedLineHeight = parseInt(element.style.lineHeight))) - ) { - format.lineHeight = ( - DEFAULT_BROWSER_LINE_HEIGHT_PERCENTAGE * - (parsedLineHeight / 100) - ).toString(); - } -} - -const listLevelParser: FormatParser = ( - format: ContentModelListItemLevelFormat, - element: HTMLElement, - _context: DomToModelContext, - defaultStyle: Readonly> -) => { - if (element.style.marginLeft != '') { - format.marginLeft = defaultStyle.marginLeft; - } - - format.marginBottom = undefined; -}; - -const wordTableParser: FormatParser = (format, element): void => { - if (format.marginLeft?.startsWith('-')) { - delete format.marginLeft; - } - if (format.htmlAlign) { - delete format.htmlAlign; - } -}; diff --git a/packages/roosterjs-content-model-plugins/lib/paste/parsers/adjustPercentileLineHeightParser.ts b/packages/roosterjs-content-model-plugins/lib/paste/parsers/adjustPercentileLineHeightParser.ts new file mode 100644 index 00000000000..f6f06ca1219 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/paste/parsers/adjustPercentileLineHeightParser.ts @@ -0,0 +1,30 @@ +import type { ContentModelBlockFormat } from 'roosterjs-content-model-types'; + +const PERCENTAGE_REGEX = /%/; +// Default line height in browsers according to https://developer.mozilla.org/en-US/docs/Web/CSS/line-height#normal +const DEFAULT_BROWSER_LINE_HEIGHT_PERCENTAGE = 1.2; + +/** + * @internal + * Parser for adjusting percentage-based line heights and converting 'normal' to a specific percentage + * @param format The block format to modify + * @param element The HTML element being processed + */ +export function adjustPercentileLineHeight( + format: ContentModelBlockFormat, + element: HTMLElement +): void { + // If the line height is less than the browser default line height, line between the text is going to be too narrow + let parsedLineHeight: number; + if ( + PERCENTAGE_REGEX.test(element.style.lineHeight) && + !isNaN((parsedLineHeight = parseInt(element.style.lineHeight))) + ) { + format.lineHeight = ( + DEFAULT_BROWSER_LINE_HEIGHT_PERCENTAGE * + (parsedLineHeight / 100) + ).toString(); + } else if (element.style.lineHeight.toLowerCase() === 'normal') { + format.lineHeight = '120%'; + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/paste/parsers/listLevelParser.ts b/packages/roosterjs-content-model-plugins/lib/paste/parsers/listLevelParser.ts new file mode 100644 index 00000000000..f0fead38520 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/paste/parsers/listLevelParser.ts @@ -0,0 +1,26 @@ +import type { + ContentModelListItemLevelFormat, + DomToModelContext, + FormatParser, +} from 'roosterjs-content-model-types'; + +/** + * @internal + * Parser for processing list level formatting specific to Word Desktop + * @param format The list item level format to modify + * @param element The HTML element being processed + * @param _context The DOM to model context + * @param defaultStyle The default style properties + */ +export const listLevelParser: FormatParser = ( + format: ContentModelListItemLevelFormat, + element: HTMLElement, + _context: DomToModelContext, + defaultStyle: Readonly> +) => { + if (element.style.marginLeft !== '') { + format.marginLeft = defaultStyle.marginLeft; + } + + format.marginBottom = undefined; +}; diff --git a/packages/roosterjs-content-model-plugins/lib/paste/parsers/wordTableParser.ts b/packages/roosterjs-content-model-plugins/lib/paste/parsers/wordTableParser.ts new file mode 100644 index 00000000000..20bb532fea2 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/paste/parsers/wordTableParser.ts @@ -0,0 +1,16 @@ +import type { ContentModelTableFormat, FormatParser } from 'roosterjs-content-model-types'; + +/** + * @internal + * Parser for processing table formatting specific to Word Desktop + * @param format The table format to modify + * @param element The HTML element being processed + */ +export const wordTableParser: FormatParser = (format, element): void => { + if (format.marginLeft?.startsWith('-')) { + delete format.marginLeft; + } + if (format.htmlAlign) { + delete format.htmlAlign; + } +}; diff --git a/packages/roosterjs-content-model-plugins/test/paste/parsers/adjustPercentileLineHeightParserTest.ts b/packages/roosterjs-content-model-plugins/test/paste/parsers/adjustPercentileLineHeightParserTest.ts new file mode 100644 index 00000000000..b92c7d177bd --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/paste/parsers/adjustPercentileLineHeightParserTest.ts @@ -0,0 +1,75 @@ +import { adjustPercentileLineHeight } from '../../../lib/paste/parsers/adjustPercentileLineHeightParser'; +import { ContentModelBlockFormat } from 'roosterjs-content-model-types'; + +describe('adjustPercentileLineHeight', () => { + let format: ContentModelBlockFormat; + let element: HTMLElement; + + beforeEach(() => { + format = {}; + element = document.createElement('p'); + }); + + it('should convert percentage line height using browser default multiplier', () => { + element.style.lineHeight = '100%'; + adjustPercentileLineHeight(format, element); + expect(format.lineHeight).toBe('1.2'); + }); + + it('should convert percentage line height with different values', () => { + element.style.lineHeight = '150%'; + adjustPercentileLineHeight(format, element); + expect(parseFloat(format.lineHeight!)).toBeCloseTo(1.8, 5); + }); + + it('should convert percentage line height with low values', () => { + element.style.lineHeight = '50%'; + adjustPercentileLineHeight(format, element); + expect(parseFloat(format.lineHeight!)).toBeCloseTo(0.6, 5); + }); + + it('should convert normal line height to 120%', () => { + element.style.lineHeight = 'normal'; + adjustPercentileLineHeight(format, element); + expect(format.lineHeight).toBe('120%'); + }); + + it('should convert NORMAL (uppercase) line height to 120% - case insensitive', () => { + element.style.lineHeight = 'NORMAL'; + adjustPercentileLineHeight(format, element); + expect(format.lineHeight).toBe('120%'); + }); + + it('should not modify line height when it is not percentage or normal', () => { + element.style.lineHeight = '1.5'; + adjustPercentileLineHeight(format, element); + expect(format.lineHeight).toBeUndefined(); + }); + + it('should not modify line height when percentage is invalid', () => { + element.style.lineHeight = 'abc%'; + adjustPercentileLineHeight(format, element); + expect(format.lineHeight).toBeUndefined(); + }); + + it('should not modify line height when no line height is set', () => { + adjustPercentileLineHeight(format, element); + expect(format.lineHeight).toBeUndefined(); + }); + + it('should handle mixed case normal values', () => { + element.style.lineHeight = 'Normal'; + adjustPercentileLineHeight(format, element); + expect(format.lineHeight).toBe('120%'); + }); + + it('should not affect existing format properties', () => { + format.marginTop = '10px'; + format.marginBottom = '5px'; + element.style.lineHeight = 'normal'; + adjustPercentileLineHeight(format, element); + expect(format.lineHeight).toBe('120%'); + expect(format.marginTop).toBe('10px'); + expect(format.marginBottom).toBe('5px'); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/paste/parsers/listLevelParserTest.ts b/packages/roosterjs-content-model-plugins/test/paste/parsers/listLevelParserTest.ts new file mode 100644 index 00000000000..04db26fc70a --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/paste/parsers/listLevelParserTest.ts @@ -0,0 +1,65 @@ +import { ContentModelListItemLevelFormat, DomToModelContext } from 'roosterjs-content-model-types'; +import { listLevelParser } from '../../../lib/paste/parsers/listLevelParser'; + +describe('listLevelParser', () => { + let format: ContentModelListItemLevelFormat; + let element: HTMLElement; + let context: DomToModelContext; + let defaultStyle: Readonly>; + + beforeEach(() => { + format = {}; + element = document.createElement('li'); + context = {} as any; + defaultStyle = {}; + }); + + it('should set marginLeft from defaultStyle when element has marginLeft', () => { + element.style.marginLeft = '20px'; + defaultStyle = { marginLeft: '15px' }; + listLevelParser(format, element, context, defaultStyle); + expect(format.marginLeft).toBe('15px'); + expect(format.marginBottom).toBeUndefined(); + }); + + it('should not set marginLeft when element marginLeft is empty', () => { + element.style.marginLeft = ''; + defaultStyle = { marginLeft: '15px' }; + listLevelParser(format, element, context, defaultStyle); + expect(format.marginLeft).toBeUndefined(); + expect(format.marginBottom).toBeUndefined(); + }); + + it('should always set marginBottom to undefined', () => { + format.marginBottom = '10px'; + listLevelParser(format, element, context, defaultStyle); + expect(format.marginBottom).toBeUndefined(); + }); + + it('should handle undefined defaultStyle marginLeft', () => { + element.style.marginLeft = '20px'; + defaultStyle = {}; + listLevelParser(format, element, context, defaultStyle); + expect(format.marginLeft).toBeUndefined(); + expect(format.marginBottom).toBeUndefined(); + }); + + it('should preserve other format properties', () => { + format.marginRight = '10px'; + format.marginTop = '5px'; + element.style.marginLeft = '20px'; + defaultStyle = { marginLeft: '15px' }; + listLevelParser(format, element, context, defaultStyle); + expect(format.marginRight).toBe('10px'); + expect(format.marginTop).toBe('5px'); + expect(format.marginLeft).toBe('15px'); + expect(format.marginBottom).toBeUndefined(); + }); + + it('should handle element with no marginLeft style', () => { + defaultStyle = { marginLeft: '15px' }; + listLevelParser(format, element, context, defaultStyle); + expect(format.marginLeft).toBeUndefined(); + expect(format.marginBottom).toBeUndefined(); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/paste/parsers/wordTableParserTest.ts b/packages/roosterjs-content-model-plugins/test/paste/parsers/wordTableParserTest.ts new file mode 100644 index 00000000000..4066a02fbfb --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/paste/parsers/wordTableParserTest.ts @@ -0,0 +1,89 @@ +import { ContentModelTableFormat, DomToModelContext } from 'roosterjs-content-model-types'; +import { wordTableParser } from '../../../lib/paste/parsers/wordTableParser'; + +describe('wordTableParser', () => { + let format: ContentModelTableFormat; + let element: HTMLElement; + let context: DomToModelContext; + let defaultStyle: Readonly>; + + beforeEach(() => { + format = {}; + element = document.createElement('table'); + context = {} as any; + defaultStyle = {}; + }); + + it('should remove marginLeft when it starts with negative value', () => { + format.marginLeft = '-5px'; + wordTableParser(format, element, context, defaultStyle); + expect(format.marginLeft).toBeUndefined(); + }); + + it('should remove marginLeft when it starts with negative number', () => { + format.marginLeft = '-10.5px'; + wordTableParser(format, element, context, defaultStyle); + expect(format.marginLeft).toBeUndefined(); + }); + + it('should not remove marginLeft when it is positive', () => { + format.marginLeft = '5px'; + wordTableParser(format, element, context, defaultStyle); + expect(format.marginLeft).toBe('5px'); + }); + + it('should not remove marginLeft when it is zero', () => { + format.marginLeft = '0px'; + wordTableParser(format, element, context, defaultStyle); + expect(format.marginLeft).toBe('0px'); + }); + + it('should remove htmlAlign property', () => { + format.htmlAlign = 'center'; + wordTableParser(format, element, context, defaultStyle); + expect(format.htmlAlign).toBeUndefined(); + }); + + it('should handle both negative marginLeft and htmlAlign together', () => { + format.marginLeft = '-8px'; + format.htmlAlign = 'start'; + wordTableParser(format, element, context, defaultStyle); + expect(format.marginLeft).toBeUndefined(); + expect(format.htmlAlign).toBeUndefined(); + }); + + it('should preserve other format properties', () => { + format.marginTop = '10px'; + format.marginRight = '5px'; + format.marginLeft = '-3px'; + format.htmlAlign = 'end'; + wordTableParser(format, element, context, defaultStyle); + expect(format.marginTop).toBe('10px'); + expect(format.marginRight).toBe('5px'); + expect(format.marginLeft).toBeUndefined(); + expect(format.htmlAlign).toBeUndefined(); + }); + + it('should handle undefined marginLeft', () => { + format.htmlAlign = 'center'; + wordTableParser(format, element, context, defaultStyle); + expect(format.marginLeft).toBeUndefined(); + expect(format.htmlAlign).toBeUndefined(); + }); + + it('should handle empty marginLeft string', () => { + format.marginLeft = ''; + format.htmlAlign = 'center'; + wordTableParser(format, element, context, defaultStyle); + expect(format.marginLeft).toBe(''); + expect(format.htmlAlign).toBeUndefined(); + }); + + it('should not affect format when no negative marginLeft or htmlAlign', () => { + format.marginLeft = '5px'; + format.marginTop = '10px'; + wordTableParser(format, element, context, defaultStyle); + expect(format.marginLeft).toBe('5px'); + expect(format.marginTop).toBe('10px'); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWordDesktopTest.ts b/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWordDesktopTest.ts index efeae75d7da..78bec107f48 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWordDesktopTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWordDesktopTest.ts @@ -211,6 +211,102 @@ describe('processPastedContentFromWordDesktopTest', () => { }); }); + it('Set line height to 120% when line-height is normal', () => { + let source = '

Test

'; + runTest(source, { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + decorator: { + format: {}, + tagName: 'p', + }, + format: { marginTop: '1em', marginBottom: '1em', lineHeight: '120%' }, + segments: [ + { + segmentType: 'Text', + text: 'Test', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Set line height to 120% when line-height is NORMAL (uppercase) - case insensitive', () => { + let source = '

Test

'; + runTest(source, { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + decorator: { + format: {}, + tagName: 'p', + }, + format: { marginTop: '1em', marginBottom: '1em', lineHeight: '120%' }, + segments: [ + { + segmentType: 'Text', + text: 'Test', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Line height with percentage should adjust percentage calculation', () => { + let source = '

Test

'; + runTest(source, { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + decorator: { + format: {}, + tagName: 'p', + }, + format: { marginTop: '1em', marginBottom: '1em', lineHeight: '0.6' }, + segments: [ + { + segmentType: 'Text', + text: 'Test', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Line height with invalid percentage should not be modified', () => { + let source = '

Test

'; + runTest(source, { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + decorator: { + format: {}, + tagName: 'p', + }, + format: { marginTop: '1em', marginBottom: '1em' }, + segments: [ + { + segmentType: 'Text', + text: 'Test', + format: {}, + }, + ], + }, + ], + }); + }); + it('Adjust Line height, percentage greater than default 2', () => { let source = '

Test

'; runTest(source, {