diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index f07ce4494c7f..06413c421b87 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -26,7 +26,7 @@ jobs: run: npm run-script build:ci - name: Install Firefox - uses: browser-actions/setup-firefox@latest + uses: browser-actions/setup-firefox@0bc507ddf224827e3b1af68e014d5e42ab93e795 # v1.7.2 - name: Generate code coverage report uses: GabrielBB/xvfb-action@b706e4e27b14669b486812790492dc50ca16b465 # v1.7 diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index c43a295adb00..ac5790877a34 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -37,7 +37,7 @@ jobs: run: yarn - name: Install Chrome - uses: browser-actions/setup-chrome@latest + uses: browser-actions/setup-chrome@48ad923757ca74d66703209fe939badbdf80f2f4 # v2.2.0 - name: Test with Chrome uses: GabrielBB/xvfb-action@b706e4e27b14669b486812790492dc50ca16b465 # v1.7 @@ -60,7 +60,7 @@ jobs: run: yarn - name: Install Firefox - uses: browser-actions/setup-firefox@latest + uses: browser-actions/setup-firefox@0bc507ddf224827e3b1af68e014d5e42ab93e795 # v1.7.2 - name: Test with Firefox uses: GabrielBB/xvfb-action@b706e4e27b14669b486812790492dc50ca16b465 # v1.7 diff --git a/packages/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts b/packages/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts index bb70be99b2f3..c15106d53b19 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts @@ -1,5 +1,4 @@ import { adjustTrailingSpaceSelection } from '../../modelApi/selection/adjustTrailingSpaceSelection'; -import { checkXss } from '../utils/checkXss'; import { matchLink } from '../../modelApi/link/matchLink'; import { addLink, @@ -8,6 +7,7 @@ import { createContentModelDocument, createText, getSelectedSegments, + isUnsafeUrl, mergeModel, } from 'roosterjs-content-model-dom'; import type { ContentModelLink, IEditor } from 'roosterjs-content-model-types'; @@ -41,7 +41,7 @@ export function insertLink( ) { editor.focus(); - const url = (checkXss(link) || '').trim(); + const url = (isUnsafeUrl(link) ? '' : link || '').trim(); if (url) { const linkData = matchLink(url); const linkUrl = linkData ? linkData.normalizedUrl : applyLinkPrefix(url); diff --git a/packages/roosterjs-content-model-api/lib/publicApi/utils/checkXss.ts b/packages/roosterjs-content-model-api/lib/publicApi/utils/checkXss.ts deleted file mode 100644 index 5f192bb4a174..000000000000 --- a/packages/roosterjs-content-model-api/lib/publicApi/utils/checkXss.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @internal Check if there is XSS attack in the link - * @param link The link to be checked - * @returns The safe link, or empty string if there is XSS attack - * @remarks This function checks for patterns like s\nc\nr\ni\np\nt: to prevent XSS attacks. This may block some valid links, - * but it is necessary for security reasons. We treat the word "script" as safe if there are "/" before it. - */ -export function checkXss(link: string): string { - return link.match(/^[^\/]*s\n*c\n*r\n*i\n*p\n*t\n*:/i) ? '' : link; -} diff --git a/packages/roosterjs-content-model-api/test/publicApi/utils/checkXssTest.ts b/packages/roosterjs-content-model-api/test/publicApi/utils/checkXssTest.ts deleted file mode 100644 index 9069906435da..000000000000 --- a/packages/roosterjs-content-model-api/test/publicApi/utils/checkXssTest.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { checkXss } from '../../../lib/publicApi/utils/checkXss'; - -describe('checkXss', () => { - it('No XSS', () => { - const link = 'https://example.com'; - expect(checkXss(link)).toBe(link); - }); - - it('With XSS', () => { - const link = 's\nc\nr\ni\np\nt:https://example.com'; - expect(checkXss(link)).toBe(''); - }); - - it('With mixed case XSS', () => { - const link = 'S\nC\nr\ni\nP\nt:https://example.com'; - expect(checkXss(link)).toBe(''); - }); - - it('With no XSS but similar pattern', () => { - const link = 'scripting:https://example.com'; - expect(checkXss(link)).toBe(link); - }); - - it('With potential XSS', () => { - const link = 'script:https://example.com'; - expect(checkXss(link)).toBe(''); - }); - - it('With script but it is safe', () => { - const link = 'https://example.com/script:.js'; - expect(checkXss(link)).toBe(link); - }); -}); diff --git a/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/sanitizeElement.ts b/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/sanitizeElement.ts index 80402409eaee..bea1ea6f8bce 100644 --- a/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/sanitizeElement.ts +++ b/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/sanitizeElement.ts @@ -1,4 +1,4 @@ -import { isNodeOfType } from 'roosterjs-content-model-dom'; +import { isNodeOfType, isUnsafeUrl } from 'roosterjs-content-model-dom'; import type { ValueSanitizer } from 'roosterjs-content-model-types'; /** @@ -105,7 +105,6 @@ export const AllowedTags: ReadonlyArray = [ 'ul', 'var', 'wbr', - 'xmp', ]; /** @@ -325,7 +324,7 @@ export function createSanitizedElement( const name = attribute.name.toLowerCase().trim(); const value = attribute.value; - const sanitizer = attributeSanitizers?.[name]; + const sanitizer = getOwnValue(attributeSanitizers, name); const newValue = name == 'style' ? processStyles(tag, value, styleSanitizers) @@ -339,11 +338,7 @@ export function createSanitizedElement( ? value : null; - if ( - newValue !== null && - newValue !== undefined && - !newValue.match(/s\n*c\n*r\n*i\n*p\n*t\n*:/i) // match script: with any NewLine inside. Browser will ignore those NewLine char and still treat it as script prefix - ) { + if (newValue !== null && newValue !== undefined && !isUnsafeUrl(newValue)) { element.setAttribute(name, newValue); } } @@ -369,7 +364,7 @@ function processStyles( value = processCssVariable(value); } - const sanitizer = styleSanitizers?.[name]; + const sanitizer = getOwnValue(styleSanitizers, name); const sanitizedValue = typeof sanitizer == 'function' ? sanitizer(value, tagName) @@ -383,7 +378,7 @@ function processStyles( sanitizedValue != 'initial' && sanitizedValue.indexOf('expression') < 0 && !name.startsWith('-') && - DefaultStyleValue[name] != sanitizedValue + getOwnValue(DefaultStyleValue, name) != sanitizedValue ) { result.push(`${name}:${sanitizedValue}`); } @@ -398,6 +393,13 @@ function processCssVariable(value: string): string { return match?.[2] || ''; // Without fallback value, we don't know what does the original value mean, so ignore it } +// Attribute and style names come from the pasted content, so a name such as "valueOf" or +// "hasOwnProperty" would otherwise resolve to the inherited member of Object.prototype and be +// invoked as if it were a sanitizer, which throws and fails the whole conversion. +function getOwnValue(map: Readonly> | undefined, name: string): T | undefined { + return map && Object.prototype.hasOwnProperty.call(map, name) ? map[name] : undefined; +} + function isCssVariable(value: string): boolean { return value.indexOf(VARIABLE_PREFIX) == 0; } diff --git a/packages/roosterjs-content-model-core/test/command/createModelFromHtml/sanitizeElementTest.ts b/packages/roosterjs-content-model-core/test/command/createModelFromHtml/sanitizeElementTest.ts index 98f8dd6aa109..1f6f2964a312 100644 --- a/packages/roosterjs-content-model-core/test/command/createModelFromHtml/sanitizeElementTest.ts +++ b/packages/roosterjs-content-model-core/test/command/createModelFromHtml/sanitizeElementTest.ts @@ -223,6 +223,44 @@ describe('sanitizeHtml', () => { runTest('aa
cc
bb', 'aaccbb'); }); + it('Html contains script url obfuscated with TAB or CR', () => { + // Browsers remove TAB, LF and CR from a URL before resolving its protocol + runTest('aaccbb', 'aaccbb'); + runTest('aaccbb', 'aaccbb'); + runTest('aaccbb', 'aaccbb'); + runTest('aaccbb', 'aaccbb'); + runTest('aabb', 'aabb'); + }); + + it('Html contains url that only looks like a script url', () => { + runTest( + 'cc', + 'cc' + ); + }); + + it('Xmp is not kept because its content is serialized without escaping', () => { + runTest( + '<img src=x onerror=alert(1)>', + '<img src=x onerror=alert(1)>' + ); + }); + + it('Style name that matches a member of Object.prototype', () => { + // Those names must not be treated as a sanitizer from the prototype chain + runTest('aa', 'aa'); + runTest( + 'aa', + 'aa' + ); + runTest('aa', 'aa'); + }); + + it('Attribute name that matches a member of Object.prototype', () => { + runTest('aa', 'aa'); + runTest('aa', 'aa'); + }); + it('Html contains unnecessary CSS', () => { runTest( 'aabbcc', diff --git a/packages/roosterjs-content-model-core/test/command/paste/pasteTest.ts b/packages/roosterjs-content-model-core/test/command/paste/pasteTest.ts index 6d3ce769f570..db30bd32aac9 100644 --- a/packages/roosterjs-content-model-core/test/command/paste/pasteTest.ts +++ b/packages/roosterjs-content-model-core/test/command/paste/pasteTest.ts @@ -192,7 +192,7 @@ describe('paste with content model & paste plugin', () => { paste(editor!, clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(2); - expect(addParserF.addParser).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 7); + expect(addParserF.addParser).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 8); expect(WordDesktopFile.processPastedContentFromWordDesktop).toHaveBeenCalledTimes(1); }); @@ -248,7 +248,7 @@ describe('paste with content model & paste plugin', () => { paste(editor!, clipboardData, 'asPlainText'); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(2); - expect(addParserF.addParser).toHaveBeenCalledTimes(12); + expect(addParserF.addParser).toHaveBeenCalledTimes(13); expect(WordDesktopFile.processPastedContentFromWordDesktop).toHaveBeenCalledTimes(1); }); diff --git a/packages/roosterjs-content-model-dom/lib/domUtils/isUnsafeUrl.ts b/packages/roosterjs-content-model-dom/lib/domUtils/isUnsafeUrl.ts new file mode 100644 index 000000000000..3c04c468113f --- /dev/null +++ b/packages/roosterjs-content-model-dom/lib/domUtils/isUnsafeUrl.ts @@ -0,0 +1,33 @@ +const UnsafeProtocols = ['javascript', 'vbscript', 'livescript', 'jscript', 'mocha', 'script']; +const IgnoredCharacters = /[\t\n\r]/g; +const MaxControlCharCode = 0x20; + +/** + * Check whether the given URL uses a protocol that can execute script, such as "javascript:" + * @param url The URL to check + * @returns True if the URL can execute script when navigated to, otherwise false + * @remarks Browsers remove every TAB, LF and CR character from a URL and ignore any leading control + * character or space before they resolve its protocol. Those characters must be removed before the + * protocol is checked here, otherwise a value such as "java script:" or "javascr ipt:" would + * be treated as safe but still executed by the browser. + */ +export function isUnsafeUrl(url: string | null | undefined): boolean { + if (!url) { + return false; + } + + const normalizedUrl = trimControlCharacters(url.replace(IgnoredCharacters, '')).toLowerCase(); + const colonIndex = normalizedUrl.indexOf(':'); + + return colonIndex >= 0 && UnsafeProtocols.indexOf(normalizedUrl.substring(0, colonIndex)) >= 0; +} + +function trimControlCharacters(url: string): string { + let start = 0; + + while (start < url.length && url.charCodeAt(start) <= MaxControlCharCode) { + start++; + } + + return url.substring(start); +} diff --git a/packages/roosterjs-content-model-dom/lib/formatHandlers/segment/linkFormatHandler.ts b/packages/roosterjs-content-model-dom/lib/formatHandlers/segment/linkFormatHandler.ts index 89d740f7629b..7b7a0dff617d 100644 --- a/packages/roosterjs-content-model-dom/lib/formatHandlers/segment/linkFormatHandler.ts +++ b/packages/roosterjs-content-model-dom/lib/formatHandlers/segment/linkFormatHandler.ts @@ -1,4 +1,5 @@ import { isElementOfType } from '../../domUtils/isElementOfType'; +import { isUnsafeUrl } from '../../domUtils/isUnsafeUrl'; import type { FormatHandler } from '../FormatHandler'; import type { LinkFormat } from 'roosterjs-content-model-types'; @@ -20,7 +21,7 @@ export const linkFormatHandler: FormatHandler = { format.name = name; } - if (href) { + if (href && !isUnsafeUrl(href)) { format.href = href; } @@ -47,7 +48,7 @@ export const linkFormatHandler: FormatHandler = { }, apply: (format, element) => { if (isElementOfType(element, 'a') && (format.href || format.name)) { - if (format.href) { + if (format.href && !isUnsafeUrl(format.href)) { element.href = format.href; } diff --git a/packages/roosterjs-content-model-dom/lib/index.ts b/packages/roosterjs-content-model-dom/lib/index.ts index 5250abd08d9e..003a65fe2bb3 100644 --- a/packages/roosterjs-content-model-dom/lib/index.ts +++ b/packages/roosterjs-content-model-dom/lib/index.ts @@ -19,6 +19,7 @@ export { buildSelectionMarker } from './domToModel/utils/buildSelectionMarker'; export { updateMetadata, getMetadata, hasMetadata } from './modelApi/metadata/updateMetadata'; export { isNodeOfType } from './domUtils/isNodeOfType'; export { isElementOfType } from './domUtils/isElementOfType'; +export { isUnsafeUrl } from './domUtils/isUnsafeUrl'; export { getObjectKeys } from './domUtils/getObjectKeys'; export { getSafeIdSelector } from './domUtils/getSafeIdSelector'; export { toArray } from './domUtils/toArray'; diff --git a/packages/roosterjs-content-model-dom/test/domUtils/isUnsafeUrlTest.ts b/packages/roosterjs-content-model-dom/test/domUtils/isUnsafeUrlTest.ts new file mode 100644 index 000000000000..8d98b42c493b --- /dev/null +++ b/packages/roosterjs-content-model-dom/test/domUtils/isUnsafeUrlTest.ts @@ -0,0 +1,49 @@ +import { isUnsafeUrl } from '../../lib/domUtils/isUnsafeUrl'; + +describe('isUnsafeUrl', () => { + it('Empty values', () => { + expect(isUnsafeUrl('')).toBeFalse(); + expect(isUnsafeUrl(null)).toBeFalse(); + expect(isUnsafeUrl(undefined)).toBeFalse(); + }); + + it('Safe urls', () => { + expect(isUnsafeUrl('https://example.com')).toBeFalse(); + expect(isUnsafeUrl('http://example.com')).toBeFalse(); + expect(isUnsafeUrl('mailto:someone@example.com')).toBeFalse(); + expect(isUnsafeUrl('data:image/png;base64,AAAA')).toBeFalse(); + expect(isUnsafeUrl('/relative/path')).toBeFalse(); + expect(isUnsafeUrl('relative/path')).toBeFalse(); + }); + + it('Safe urls that look similar to a script protocol', () => { + expect(isUnsafeUrl('scripting:https://example.com')).toBeFalse(); + expect(isUnsafeUrl('transcript:https://example.com')).toBeFalse(); + expect(isUnsafeUrl('https://example.com/script:.js')).toBeFalse(); + expect(isUnsafeUrl('/redirect?url=javascript:alert(1)')).toBeFalse(); + }); + + it('Unsafe protocols', () => { + expect(isUnsafeUrl('javascript:alert(1)')).toBeTrue(); + expect(isUnsafeUrl('vbscript:msgbox(1)')).toBeTrue(); + expect(isUnsafeUrl('script:alert(1)')).toBeTrue(); + expect(isUnsafeUrl('JaVaScRiPt:alert(1)')).toBeTrue(); + }); + + it('Unsafe protocols obfuscated with characters that browsers ignore', () => { + // Browsers strip TAB, LF and CR from anywhere inside a URL + expect(isUnsafeUrl('java\tscript:alert(1)')).toBeTrue(); + expect(isUnsafeUrl('javascr\ript:alert(1)')).toBeTrue(); + expect(isUnsafeUrl('javascr\nipt:alert(1)')).toBeTrue(); + expect(isUnsafeUrl('j\ta\nv\ra\ts\nc\rr\ti\np\rt:alert(1)')).toBeTrue(); + + // Cases previously covered by checkXss + expect(isUnsafeUrl('s\nc\nr\ni\np\nt:https://example.com')).toBeTrue(); + expect(isUnsafeUrl('S\nC\nr\ni\nP\nt:https://example.com')).toBeTrue(); + + // Browsers ignore leading control characters and spaces + expect(isUnsafeUrl(' javascript:alert(1)')).toBeTrue(); + expect(isUnsafeUrl('\u0001javascript:alert(1)')).toBeTrue(); + expect(isUnsafeUrl(' java\tscript:alert(1)')).toBeTrue(); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts b/packages/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts index 2fc802337679..6f3e3570a5d1 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts @@ -102,6 +102,7 @@ export function handleTabOnParagraph( if (markerIndex <= 0) { return false; } + const tabText = paragraph.segments[markerIndex - 1]; const tabSpacesLength = tabSpaces.length; @@ -117,6 +118,8 @@ export function handleTabOnParagraph( } else { return false; } + } else { + return false; } } } 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 9fc9f592bf9f..77b2f05a7aed 100644 --- a/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts +++ b/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts @@ -11,6 +11,7 @@ import { setProcessor } from '../utils/setProcessor'; import { wordContainerParser } from '../parsers/wordContainerParser'; import { wordTableParser } from '../parsers/wordTableParser'; import { removeListParagraphMargins } from './removeListParagraphMargins'; +import { wordBorderParser } from './wordBorderParser'; import type { WordMetadata } from './WordMetadata'; import type { CssRule, DomToModelOption, ElementProcessor } from 'roosterjs-content-model-types'; @@ -38,6 +39,7 @@ export function processPastedContentFromWordDesktop( addParser(domToModelOption, 'listLevel', listLevelParser); addParser(domToModelOption, 'container', wordContainerParser); addParser(domToModelOption, 'table', wordTableParser); + addParser(domToModelOption, 'tableBorder', wordBorderParser); } const wordDesktopElementProcessor = ( diff --git a/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/processWordLists.ts b/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/processWordLists.ts index eab556f3b711..d546e6b4d23a 100644 --- a/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/processWordLists.ts +++ b/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/processWordLists.ts @@ -85,8 +85,14 @@ export function processWordList( setupListFormat(listType, element, context, wordLevel, listFormat, group, [ wordListPaddingParser, ]); - (listFormat.levels[listFormat.levels.length - 1] - .format as WordListFormat).wordList = wordList; + const currentLevelFormat = listFormat.levels[listFormat.levels.length - 1] + .format as WordListFormat; + currentLevelFormat.wordList = wordList; + + if (wordLevel > 1) { + delete currentLevelFormat.paddingLeft; + delete currentLevelFormat.paddingRight; + } const bullet = getBulletFromMetadata(listMetadata, listType); const listFormatMetadata = bullet @@ -103,6 +109,11 @@ export function processWordList( listFormatMetadata, getBulletElement(element), listItem => { + if (wordLevel > 1) { + delete listItem.format.marginLeft; + delete listItem.format.marginRight; + } + if (listType == 'OL') { setStartNumber(listItem, context, listMetadata, element); } diff --git a/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/removeListParagraphMargins.ts b/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/removeListParagraphMargins.ts index 44f2e1e5f49e..666dac3b4bab 100644 --- a/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/removeListParagraphMargins.ts +++ b/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/removeListParagraphMargins.ts @@ -18,36 +18,117 @@ const WORD_LIST_PARAGRAPH_SELECTORS = new Set([ /** * @internal - * Strips all margin-* properties from a CSS property string. + * Strips horizontal margin properties from a CSS property string while preserving + * vertical values from the margin shorthand. * Empty tokens produced by a trailing semicolon are preserved so that the * resulting string still ends with ";" and remains safe to concatenate. - * For example, "margin-top: 0pt; color: red;" becomes " color: red;". + * For example, "margin: 1pt 2pt 3pt 4pt; color: red;" becomes + * "margin-top: 1pt; margin-bottom: 3pt; color: red;". */ -function removeMarginProperties(cssText: string): string { - return cssText - .split(';') - .filter(prop => { - const name = prop.split(':')[0].trim().toLowerCase(); - // Keep empty tokens (the trailing ';' produces one) and any non-margin property. - return !name || !/^margin/.test(name); - }) - .join(';'); +function removeHorizontalMarginProperties(cssText: string): string { + const result: string[] = []; + + cssText.split(';').forEach(prop => { + const separatorIndex = prop.indexOf(':'); + const name = prop + .substring(0, separatorIndex < 0 ? prop.length : separatorIndex) + .trim() + .toLowerCase(); + + if ( + name == 'margin-left' || + name == 'margin-right' || + name == 'margin-inline' || + name == 'margin-inline-start' || + name == 'margin-inline-end' + ) { + return; + } + + if (name == 'margin' && separatorIndex >= 0) { + const value = prop.substring(separatorIndex + 1).trim(); + const importantMatch = value.match(/\s*!important\s*$/i); + const important = importantMatch ? ' !important' : ''; + const values = splitCssValue(value.substring(0, importantMatch?.index ?? value.length)); + + if (values.length >= 1 && values.length <= 4) { + const leadingWhitespace = prop.match(/^\s*/)?.[0] || ''; + const top = values[0]; + const bottom = values.length >= 3 ? values[2] : values[0]; + + result.push(`${leadingWhitespace}margin-top: ${top}${important}`); + result.push(` margin-bottom: ${bottom}${important}`); + return; + } + } + + result.push(prop); + }); + + return result.join(';'); +} + +function splitCssValue(value: string): string[] { + const result: string[] = []; + let current = ''; + let quote: string | null = null; + let parenthesisDepth = 0; + + for (let i = 0; i < value.length; i++) { + const char = value[i]; + + if (quote) { + current += char; + + if (char == '\\') { + i++; + current += value[i] || ''; + } else if (char == quote) { + quote = null; + } + } else if (char == '"' || char == "'") { + current += char; + quote = char; + } else if (char == '(') { + current += char; + parenthesisDepth++; + } else if (char == ')') { + current += char; + parenthesisDepth = Math.max(0, parenthesisDepth - 1); + } else if (parenthesisDepth == 0 && /\s/.test(char)) { + if (current) { + result.push(current); + current = ''; + } + } else { + current += char; + } + } + + if (current) { + result.push(current); + } + + return result; } /** * @internal - * Removes margin properties from global CSS rules that target Word list paragraph - * classes (p.MsoListParagraph, p.MsoListParagraphCxSpFirst, etc.). + * Removes horizontal margin properties from global CSS rules that target Word list + * paragraph classes (p.MsoListParagraph, p.MsoListParagraphCxSpFirst, etc.). * * Word Desktop pastes a global stylesheet that typically includes rules like: * p.MsoListParagraph { margin: 0in; margin-bottom: .0001pt; ... } - * These margins conflict with RoosterJS's own list indentation, causing double - * indentation when the CSS is converted to inline styles via convertInlineCss. + * Horizontal margins conflict with RoosterJS's own list indentation, causing double + * indentation when the CSS is converted to inline styles via convertInlineCss. The + * vertical margins are preserved so Word's margin-top: 0 is not replaced by the + * browser's default paragraph margin when the list level is created. * - * When a rule's selectors are exclusively list paragraph classes the margins are - * removed in place. When a rule groups list paragraph classes with other selectors - * the rule is split: the non-list selectors keep the original text, and a new rule - * is inserted for the list paragraph selectors with margins stripped. + * When a rule's selectors are exclusively list paragraph classes the horizontal + * margins are removed in place. When a rule groups list paragraph classes with other + * selectors the rule is split: the non-list selectors keep the original text, and a + * new rule is inserted for the list paragraph selectors with horizontal margins + * stripped. * * The array is mutated in place so the changes are reflected when convertInlineCss * subsequently processes the same array reference. @@ -67,16 +148,15 @@ export function removeListParagraphMargins(globalCssRules: CssRule[]): void { ); if (nonMatchingSelectors.length === 0) { - // All selectors target list paragraphs — strip margins directly. - rule.text = removeMarginProperties(rule.text); + // All selectors target list paragraphs — strip horizontal margins directly. + rule.text = removeHorizontalMarginProperties(rule.text); } else { // Mixed rule: keep the non-list selectors on the original entry, then - // insert a new entry immediately after for the list paragraph selectors - // with margins removed. + // insert a new entry immediately after for the list paragraph selectors. rule.selectors = nonMatchingSelectors; globalCssRules.splice(i + 1, 0, { selectors: matchingSelectors, - text: removeMarginProperties(rule.text), + text: removeHorizontalMarginProperties(rule.text), }); } } diff --git a/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/wordBorderParser.ts b/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/wordBorderParser.ts new file mode 100644 index 000000000000..8767b5f5b6b8 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/wordBorderParser.ts @@ -0,0 +1,55 @@ +import { BorderKeys } from 'roosterjs-content-model-dom'; +import type { + BorderFormat, + ContentModelTableFormat, + FormatParser, +} from 'roosterjs-content-model-types'; + +const BorderStyleNames = new Map([ + ['border-top', 'borderTop'], + ['border-right', 'borderRight'], + ['border-bottom', 'borderBottom'], + ['border-left', 'borderLeft'], +]); + +/** + * @internal + */ +export const wordBorderParser: FormatParser = (format, element) => { + const style = element.getAttribute('style'); + + if (style) { + const noBorder = new Map(); + + style.split(';').forEach(declaration => { + const separatorIndex = declaration.indexOf(':'); + + if (separatorIndex < 0) { + return; + } + + const name = declaration.substring(0, separatorIndex).trim().toLowerCase(); + const value = declaration + .substring(separatorIndex + 1) + .trim() + .toLowerCase(); + const isNone = /^none(?:\s|$|!important)/.test(value); + + if (name == 'border') { + BorderKeys.forEach(key => noBorder.set(key, isNone)); + } else { + const key = BorderStyleNames.get(name); + + if (key) { + noBorder.set(key, isNone); + } + } + }); + + noBorder.forEach((isNone, key) => { + if (isNone) { + format[key] = 'none'; + } + }); + } +}; diff --git a/packages/roosterjs-content-model-plugins/lib/paste/parsers/linkParser.ts b/packages/roosterjs-content-model-plugins/lib/paste/parsers/linkParser.ts index 2e508a52c6bb..5642b2b8103e 100644 --- a/packages/roosterjs-content-model-plugins/lib/paste/parsers/linkParser.ts +++ b/packages/roosterjs-content-model-plugins/lib/paste/parsers/linkParser.ts @@ -1,4 +1,4 @@ -import { isElementOfType } from 'roosterjs-content-model-dom'; +import { isElementOfType, isUnsafeUrl } from 'roosterjs-content-model-dom'; import type { ContentModelHyperLinkFormat, FormatParser } from 'roosterjs-content-model-types'; const SUPPORTED_PROTOCOLS = ['http:', 'https:', 'notes:', 'mailto:', 'onenote:']; @@ -12,16 +12,22 @@ export const parseLink: FormatParser = (format, ele return; } + // On an SVG anchor "href" is an SVGAnimatedString instead of a resolved URL string. Passing it + // to the URL constructor always throws, which used to skip the protocol check altogether. + const href = + typeof element.href == 'string' ? element.href : element.getAttribute('href') ?? ''; + let url: URL | undefined; try { - url = new URL(element.href); + url = new URL(href); } catch { url = undefined; } if ( + isUnsafeUrl(href) || (url && SUPPORTED_PROTOCOLS.indexOf(url.protocol) === -1) || - INVALID_LINKS_REGEX.test(element.href) + INVALID_LINKS_REGEX.test(href) ) { element.removeAttribute('href'); format.href = ''; diff --git a/packages/roosterjs-content-model-plugins/lib/paste/utils/customListUtils.ts b/packages/roosterjs-content-model-plugins/lib/paste/utils/customListUtils.ts index 8d5bae3e6c79..c7c49988d634 100644 --- a/packages/roosterjs-content-model-plugins/lib/paste/utils/customListUtils.ts +++ b/packages/roosterjs-content-model-plugins/lib/paste/utils/customListUtils.ts @@ -71,9 +71,14 @@ export function processAsListItem( updateListMetadata(lastLevel, metadata => Object.assign({}, metadata, listFormatMetadata)); } + // Parse the list item element's own segment format (e.g. font family/size) into + // context.segmentFormat *before* creating the list item, so the format holder + // (used to render the list bullet/number) reflects this list item's own font instead + // of whatever was left over from the previously processed element. + parseFormat(element, context.formatParsers.segmentOnBlock, context.segmentFormat, context); + const listItem = createListItem(listFormat.levels, context.segmentFormat); - parseFormat(element, context.formatParsers.segmentOnBlock, context.segmentFormat, context); parseFormat(element, context.formatParsers.listItemElement, listItem.format, context); parseFormat( element, diff --git a/packages/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnParagraphTest.ts b/packages/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnParagraphTest.ts index f770a1961107..a3ca2592b7c0 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnParagraphTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnParagraphTest.ts @@ -332,6 +332,49 @@ describe('handleTabOnParagraph', () => { runTest(model, paragraph, rawEvent, true); }); + it('Outdent - collapsed range should return false when only entities exist before the marker', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + blockType: 'Entity', + segmentType: 'Entity', + format: {}, + entityFormat: { + id: 'entity-1', + entityType: 'test', + isReadonly: true, + }, + wrapper: document.createElement('span'), + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const paragraph = model.blocks[0] as ContentModelParagraph; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + }); + + runTest(model, paragraph, rawEvent, false); + }); + it('Indent - expanded range should return true', () => { const model: ContentModelDocument = { blockGroupType: 'Document', diff --git a/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromPowerPointDesktopTest.ts b/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromPowerPointDesktopTest.ts index a514c3cbc56d..9e7b323a0512 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromPowerPointDesktopTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromPowerPointDesktopTest.ts @@ -34,6 +34,9 @@ describe('PowerPoint', () => { }); it('PowerPoint 1', () => { + const fontStyle = document.createElement('span').style; + fontStyle.fontFamily = 'Courier New'; + paste(editor!, pptClipboardContent1, 'normal'); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(2); @@ -106,7 +109,7 @@ describe('PowerPoint', () => { isSelected: false, segmentType: 'SelectionMarker', format: { - fontFamily: '"Courier New"', + fontFamily: fontStyle.fontFamily, fontSize: '7pt', textColor: 'rgb(0, 0, 0)', }, diff --git a/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts b/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts index dcc813b5a13b..4088d3b073c4 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts @@ -863,14 +863,14 @@ describe(ID, () => { listType: 'OL', format: { startNumberOverride: 1, - marginTop: '1em', + marginTop: '0cm', listStyleType: 'decimal', }, dataset: { editingInfo: '{"orderedStyleType":1}' }, }, ], blockType: 'BlockGroup', - format: { lineHeight: '115%' }, + format: { marginTop: '0cm', marginBottom: '0cm', lineHeight: '115%' }, blockGroupType: 'ListItem', blocks: [ { @@ -909,12 +909,12 @@ describe(ID, () => { levels: [ { listType: 'OL', - format: { marginTop: '1em', listStyleType: 'decimal' }, + format: { marginTop: '0cm', listStyleType: 'decimal' }, dataset: { editingInfo: '{"orderedStyleType":1}' }, }, ], blockType: 'BlockGroup', - format: { lineHeight: '115%' }, + format: { marginTop: '0cm', marginBottom: '0cm', lineHeight: '115%' }, blockGroupType: 'ListItem', blocks: [ { @@ -953,12 +953,12 @@ describe(ID, () => { levels: [ { listType: 'OL', - format: { marginTop: '1em', listStyleType: 'decimal' }, + format: { marginTop: '0cm', listStyleType: 'decimal' }, dataset: { editingInfo: '{"orderedStyleType":1}' }, }, ], blockType: 'BlockGroup', - format: { lineHeight: '115%' }, + format: { marginTop: '0cm', marginBottom: '0cm', lineHeight: '115%' }, blockGroupType: 'ListItem', blocks: [ { @@ -997,22 +997,25 @@ describe(ID, () => { levels: [ { listType: 'OL', - format: { marginTop: '1em', listStyleType: 'decimal' }, + format: { marginTop: '0cm', listStyleType: 'decimal' }, dataset: { editingInfo: '{"orderedStyleType":1}' }, }, { listType: 'OL', format: { startNumberOverride: 1, - marginTop: '1em', - paddingLeft: '0px', + marginTop: '0cm', listStyleType: 'lower-alpha', }, dataset: { editingInfo: '{"orderedStyleType":5}' }, }, ], blockType: 'BlockGroup', - format: { lineHeight: '115%', marginLeft: '72pt' }, + format: { + marginTop: '0cm', + marginBottom: '0cm', + lineHeight: '115%', + }, blockGroupType: 'ListItem', blocks: [ { @@ -1051,21 +1054,24 @@ describe(ID, () => { levels: [ { listType: 'OL', - format: { marginTop: '1em', listStyleType: 'decimal' }, + format: { marginTop: '0cm', listStyleType: 'decimal' }, dataset: { editingInfo: '{"orderedStyleType":1}' }, }, { listType: 'OL', format: { - marginTop: '1em', - paddingLeft: '0px', + marginTop: '0cm', listStyleType: 'lower-alpha', }, dataset: { editingInfo: '{"orderedStyleType":5}' }, }, ], blockType: 'BlockGroup', - format: { lineHeight: '115%', marginLeft: '72pt' }, + format: { + marginTop: '0cm', + marginBottom: '0cm', + lineHeight: '115%', + }, blockGroupType: 'ListItem', blocks: [ { @@ -1104,12 +1110,12 @@ describe(ID, () => { levels: [ { listType: 'OL', - format: { marginTop: '1em', listStyleType: 'decimal' }, + format: { marginTop: '0cm', listStyleType: 'decimal' }, dataset: { editingInfo: '{"orderedStyleType":1}' }, }, ], blockType: 'BlockGroup', - format: { lineHeight: '115%' }, + format: { marginTop: '0cm', marginBottom: '8pt', lineHeight: '115%' }, blockGroupType: 'ListItem', blocks: [ { diff --git a/packages/roosterjs-content-model-plugins/test/paste/e2e/wordNestedListMarginTest.ts b/packages/roosterjs-content-model-plugins/test/paste/e2e/wordNestedListMarginTest.ts new file mode 100644 index 000000000000..2d8513d52217 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/paste/e2e/wordNestedListMarginTest.ts @@ -0,0 +1,109 @@ +import * as wordFile from '../../../lib/paste/WordDesktop/processPastedContentFromWordDesktop'; +import { initEditor } from './testUtils'; +import { itChromeOnly } from 'roosterjs-content-model-dom/test/testUtils'; +import { paste } from 'roosterjs-content-model-core'; +import type { ClipboardData, ContentModelListItem, IEditor } from 'roosterjs-content-model-types'; + +const ID = 'Word_Nested_List_Margin'; + +describe(ID, () => { + let editor: IEditor; + + beforeEach(() => { + editor = initEditor(ID); + spyOn(wordFile, 'processPastedContentFromWordDesktop').and.callThrough(); + }); + + afterEach(() => { + document.getElementById(ID)?.remove(); + }); + + itChromeOnly('does not add top margins to nested Word list levels', () => { + const rawHtml = ` + + + + + + +

+ ·  Desktop Outlook +

+

+ o  Compose window +

+

+ o  Received message +

+

+ § Expanded reading pane +

+

+ § Pop-out window +

+ + +`; + const clipboardData = { + types: ['text/plain', 'text/html'], + text: '', + image: null, + files: [], + rawHtml, + customValues: {}, + pasteNativeEvent: true, + } as ClipboardData; + + paste(editor, clipboardData); + + expect(wordFile.processPastedContentFromWordDesktop).toHaveBeenCalled(); + + const listItems = editor + .getContentModelCopy('disconnected') + .blocks.filter( + (block): block is ContentModelListItem => + block.blockType == 'BlockGroup' && block.blockGroupType == 'ListItem' + ); + + expect(listItems.length).toBe(5); + expect(listItems[1].levels[1].format.marginTop).toBe('0in'); + expect(listItems[3].levels[2].format.marginTop).toBe('0in'); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/paste/plugin/ContentModelPastePluginTest.ts b/packages/roosterjs-content-model-plugins/test/paste/plugin/ContentModelPastePluginTest.ts index b3dd98241be7..7848b60501f6 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/plugin/ContentModelPastePluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/plugin/ContentModelPastePluginTest.ts @@ -59,7 +59,7 @@ describe('Content Model Paste Plugin Test', () => { plugin.initialize(editor); plugin.onPluginEvent(event); - expect(addParser.addParser).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 7); + expect(addParser.addParser).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 8); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(2); }); 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 20e8533dcd27..8be3d37b88d0 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWordDesktopTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWordDesktopTest.ts @@ -1254,9 +1254,7 @@ describe('processPastedContentFromWordDesktopTest', () => { }, ], blockType: 'BlockGroup', - format: { - marginLeft: '0in', - }, + format: {}, blockGroupType: 'ListItem', blocks: [ { @@ -1302,7 +1300,6 @@ describe('processPastedContentFromWordDesktopTest', () => { listType: 'OL', format: { marginTop: '1em', - paddingLeft: '0px', wordList: 'l0', }, dataset: { @@ -1311,9 +1308,7 @@ describe('processPastedContentFromWordDesktopTest', () => { }, ], blockType: 'BlockGroup', - format: { - marginLeft: '104px', - }, + format: {}, blockGroupType: 'ListItem', blocks: [ { @@ -1359,7 +1354,6 @@ describe('processPastedContentFromWordDesktopTest', () => { listType: 'OL', format: { marginTop: '1em', - paddingLeft: '0px', wordList: 'l0', }, dataset: { @@ -1378,9 +1372,7 @@ describe('processPastedContentFromWordDesktopTest', () => { }, ], blockType: 'BlockGroup', - format: { - marginLeft: '0in', - }, + format: {}, blockGroupType: 'ListItem', blocks: [ { @@ -2789,7 +2781,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l1', startNumberOverride: 1, }, @@ -2804,9 +2795,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { lineHeight: '116%', marginTop: '0in', - marginRight: '0in', marginBottom: '0in', - marginLeft: '1in', }, }, { @@ -2848,7 +2837,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}' }, @@ -2858,7 +2846,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l2', startNumberOverride: 1, }, @@ -2873,9 +2860,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { lineHeight: '116%', marginTop: '0in', - marginRight: '0in', marginBottom: '0in', - marginLeft: '1.5in', }, }, { @@ -2917,7 +2902,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}' }, @@ -2927,7 +2911,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l2', }, dataset: { editingInfo: '{"orderedStyleType":13}' }, @@ -2937,7 +2920,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', startNumberOverride: 1, }, @@ -2952,9 +2934,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { lineHeight: '116%', marginTop: '0in', - marginRight: '0in', marginBottom: '0in', - marginLeft: '2in', }, }, { @@ -2996,7 +2976,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}' }, @@ -3006,7 +2985,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l2', }, dataset: { editingInfo: '{"orderedStyleType":13}' }, @@ -3016,7 +2994,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":1}' }, @@ -3026,7 +3003,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', startNumberOverride: 1, }, @@ -3041,9 +3017,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { lineHeight: '116%', marginTop: '0in', - marginRight: '0in', marginBottom: '0in', - marginLeft: '2.5in', }, }, { @@ -3085,7 +3059,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}' }, @@ -3095,7 +3068,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l2', }, dataset: { editingInfo: '{"orderedStyleType":13}' }, @@ -3105,7 +3077,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":1}' }, @@ -3115,7 +3086,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":3}' }, @@ -3125,7 +3095,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', startNumberOverride: 1, }, @@ -3140,9 +3109,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { lineHeight: '116%', marginTop: '0in', - marginRight: '0in', marginBottom: '0in', - marginLeft: '3in', }, }, { @@ -3184,7 +3151,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}' }, @@ -3194,7 +3160,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l2', }, dataset: { editingInfo: '{"orderedStyleType":13}' }, @@ -3204,7 +3169,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":1}' }, @@ -3214,7 +3178,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":3}' }, @@ -3224,7 +3187,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}' }, @@ -3234,7 +3196,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', startNumberOverride: 1, }, @@ -3249,9 +3210,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { lineHeight: '116%', marginTop: '0in', - marginRight: '0in', marginBottom: '0in', - marginLeft: '3.5in', }, }, { @@ -3293,7 +3252,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}' }, @@ -3303,7 +3261,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l2', }, dataset: { editingInfo: '{"orderedStyleType":13}' }, @@ -3313,7 +3270,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":1}' }, @@ -3323,7 +3279,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":3}' }, @@ -3333,7 +3288,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}' }, @@ -3343,7 +3297,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}' }, @@ -3353,7 +3306,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', startNumberOverride: 500, }, @@ -3368,9 +3320,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { lineHeight: '116%', marginTop: '0in', - marginRight: '0in', marginBottom: '0in', - marginLeft: '4in', }, }, { @@ -3412,7 +3362,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}' }, @@ -3422,7 +3371,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l2', }, dataset: { editingInfo: '{"orderedStyleType":13}' }, @@ -3432,7 +3380,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":1}' }, @@ -3442,7 +3389,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":3}' }, @@ -3452,7 +3398,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}' }, @@ -3462,7 +3407,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}' }, @@ -3472,7 +3416,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":5}' }, @@ -3482,7 +3425,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', startNumberOverride: 6, }, @@ -3497,9 +3439,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { lineHeight: '116%', marginTop: '0in', - marginRight: '0in', marginBottom: '0in', - marginLeft: '4.5in', }, }, { @@ -3541,7 +3481,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}' }, @@ -3551,7 +3490,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l2', }, dataset: { editingInfo: '{"orderedStyleType":13}' }, @@ -3561,7 +3499,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":1}' }, @@ -3571,7 +3508,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":3}' }, @@ -3581,7 +3517,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}' }, @@ -3591,7 +3526,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}' }, @@ -3601,7 +3535,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":5}' }, @@ -3615,9 +3548,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { lineHeight: '116%', marginTop: '0in', - marginRight: '0in', marginBottom: '0in', - marginLeft: '4in', }, }, { @@ -3659,7 +3590,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}' }, @@ -3669,7 +3599,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l2', }, dataset: { editingInfo: '{"orderedStyleType":13}' }, @@ -3679,7 +3608,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":1}' }, @@ -3689,7 +3617,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":3}' }, @@ -3699,7 +3626,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}' }, @@ -3709,7 +3635,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}' }, @@ -3723,9 +3648,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { lineHeight: '116%', marginTop: '0in', - marginRight: '0in', marginBottom: '0in', - marginLeft: '3.5in', }, }, { @@ -3767,7 +3690,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}' }, @@ -3777,7 +3699,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l2', }, dataset: { editingInfo: '{"orderedStyleType":13}' }, @@ -3787,7 +3708,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":1}' }, @@ -3797,7 +3717,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":3}' }, @@ -3807,7 +3726,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}' }, @@ -3821,9 +3739,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { lineHeight: '116%', marginTop: '0in', - marginRight: '0in', marginBottom: '0in', - marginLeft: '3in', }, }, { @@ -3865,7 +3781,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}' }, @@ -3875,7 +3790,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l2', }, dataset: { editingInfo: '{"orderedStyleType":13}' }, @@ -3885,7 +3799,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":1}' }, @@ -3895,7 +3808,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":3}' }, @@ -3909,9 +3821,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { lineHeight: '116%', marginTop: '0in', - marginRight: '0in', marginBottom: '0in', - marginLeft: '2.5in', }, }, { @@ -3953,7 +3863,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}' }, @@ -3963,7 +3872,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l2', }, dataset: { editingInfo: '{"orderedStyleType":13}' }, @@ -3973,7 +3881,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":1}' }, @@ -3987,9 +3894,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { lineHeight: '116%', marginTop: '0in', - marginRight: '0in', marginBottom: '0in', - marginLeft: '2in', }, }, { @@ -4031,7 +3936,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l1', }, dataset: { editingInfo: '{"orderedStyleType":5}' }, @@ -4041,7 +3945,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":13}' }, @@ -4055,9 +3958,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { lineHeight: '116%', marginTop: '0in', - marginRight: '0in', marginBottom: '0in', - marginLeft: '1.5in', }, }, { @@ -4099,7 +4000,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l3', }, dataset: { editingInfo: '{"orderedStyleType":5}' }, @@ -4113,9 +4013,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { lineHeight: '116%', marginTop: '0in', - marginRight: '0in', marginBottom: '0in', - marginLeft: '1in', }, }, { @@ -4333,7 +4231,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l2', }, dataset: {}, @@ -4347,9 +4244,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { lineHeight: '107%', marginTop: '0in', - marginRight: '0in', marginBottom: '8pt', - marginLeft: '1in', }, }, { @@ -4424,7 +4319,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l2', }, dataset: {}, @@ -4438,9 +4332,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { lineHeight: '107%', marginTop: '0in', - marginRight: '0in', marginBottom: '8pt', - marginLeft: '1in', }, }, { @@ -4476,7 +4368,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l2', }, dataset: {}, @@ -4486,7 +4377,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l1', }, dataset: {}, @@ -4500,9 +4390,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { lineHeight: '107%', marginTop: '0in', - marginRight: '0in', marginBottom: '8pt', - marginLeft: '117pt', }, }, { @@ -4577,7 +4465,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l2', }, dataset: {}, @@ -4591,9 +4478,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { lineHeight: '107%', marginTop: '0in', - marginRight: '0in', marginBottom: '8pt', - marginLeft: '1in', }, }, { @@ -4725,7 +4610,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l0', }, dataset: {}, @@ -4739,9 +4623,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { lineHeight: '105%', marginTop: '0in', - marginRight: '0in', marginBottom: '8pt', - marginLeft: '1in', }, }, { @@ -4821,7 +4703,6 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { marginTop: '0in', marginRight: '0in', - paddingLeft: '0px', wordList: 'l0', }, dataset: {}, @@ -4835,9 +4716,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: { lineHeight: '105%', marginTop: '0in', - marginRight: '0in', marginBottom: '8pt', - marginLeft: '1in', }, }, ], @@ -5056,7 +4935,6 @@ describe('processPastedContentFromWordDesktopTest', () => { listType: 'OL', format: { marginTop: '1em', - paddingLeft: '0px', wordList: 'l0', startNumberOverride: 1, }, @@ -5070,9 +4948,7 @@ describe('processPastedContentFromWordDesktopTest', () => { isSelected: false, format: {}, }, - format: { - marginLeft: '1in', - }, + format: {}, }, { blockType: 'Paragraph', @@ -5125,7 +5001,6 @@ describe('processPastedContentFromWordDesktopTest', () => { listType: 'OL', format: { marginTop: '1em', - paddingLeft: '0px', wordList: 'l0', }, dataset: { @@ -5136,7 +5011,6 @@ describe('processPastedContentFromWordDesktopTest', () => { listType: 'OL', format: { marginTop: '1em', - paddingLeft: '0px', wordList: 'l0', startNumberOverride: 1, }, @@ -5150,9 +5024,7 @@ describe('processPastedContentFromWordDesktopTest', () => { isSelected: false, format: {}, }, - format: { - marginLeft: '1.5in', - }, + format: {}, }, { blockType: 'Paragraph', @@ -5179,6 +5051,134 @@ describe('processPastedContentFromWordDesktopTest', () => { htmlBefore ); }); + + it('List item format holder should use its own font, not a stale font from a previous element, when there is no Word fake bullet markup', () => { + const html = + '

Text before the list

' + + '
    ' + + '
  • Item 1
  • ' + + '
  • Item 2
  • ' + + '
'; + + runTest(html, { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Text before the list', + format: {}, + }, + ], + format: { + marginTop: '1em', + marginBottom: '1em', + }, + decorator: { + tagName: 'p', + format: {}, + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Item 1', + format: { + fontFamily: 'Aptos', + fontSize: '10pt', + }, + }, + ], + format: {}, + isImplicit: true, + segmentFormat: { + fontFamily: 'Aptos', + fontSize: '10pt', + }, + }, + ], + levels: [ + { + listType: 'OL', + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + format: { + wordList: 'l0', + marginBottom: undefined, + }, + }, + ], + // This is the key assertion: the format holder (used to render the list + // bullet/number) must reflect this list item's own font, not whatever was + // left over in context.segmentFormat from the previous paragraph. + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: { + fontFamily: 'Aptos', + fontSize: '10pt', + }, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Item 2', + format: { + fontFamily: 'Aptos', + fontSize: '10pt', + }, + }, + ], + format: {}, + isImplicit: true, + segmentFormat: { + fontFamily: 'Aptos', + fontSize: '10pt', + }, + }, + ], + levels: [ + { + listType: 'OL', + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + format: { + wordList: 'l0', + marginBottom: undefined, + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: { + fontFamily: 'Aptos', + fontSize: '10pt', + }, + }, + format: {}, + }, + ], + }); + }); }); }); diff --git a/packages/roosterjs-content-model-plugins/test/paste/word/removeListParagraphMarginsTest.ts b/packages/roosterjs-content-model-plugins/test/paste/word/removeListParagraphMarginsTest.ts index d33c27c96d0e..3d2324568437 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/word/removeListParagraphMarginsTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/word/removeListParagraphMarginsTest.ts @@ -14,7 +14,7 @@ describe('removeListParagraphMargins', () => { runTest(rules, [{ selectors: ['div.SomeClass'], text: 'margin: 0pt; color: red;' }]); }); - it('all selectors are list paragraph classes — margins removed', () => { + it('all selectors are list paragraph classes — vertical margins preserved', () => { const rules: CssRule[] = [ { selectors: ['p.MsoListParagraph', 'p.MsoListParagraphCxSpFirst'], @@ -24,12 +24,12 @@ describe('removeListParagraphMargins', () => { runTest(rules, [ { selectors: ['p.MsoListParagraph', 'p.MsoListParagraphCxSpFirst'], - text: ' color: red; font-size: 11pt;', + text: 'margin-top: 0pt; margin-bottom: 8pt; color: red; font-size: 11pt;', }, ]); }); - it('single list paragraph selector — margins removed', () => { + it('single list paragraph selector — shorthand preserves vertical margins', () => { const rules: CssRule[] = [ { selectors: ['p.MsoListParagraphCxSpMiddle'], @@ -39,7 +39,7 @@ describe('removeListParagraphMargins', () => { runTest(rules, [ { selectors: ['p.MsoListParagraphCxSpMiddle'], - text: ' font-family: Calibri;', + text: 'margin-top: 0; margin-bottom: 0; font-family: Calibri;', }, ]); }); @@ -131,8 +131,8 @@ describe('removeListParagraphMargins', () => { { selectors: ['ul'], text: 'margin-bottom: 0in;' }, ]; - const noMarginListText = - ' font-size: 10pt; font-family: Aptos, sans-serif; color: rgb(25, 25, 25);'; + const noHorizontalMarginListText = + 'margin-top: 0in; margin-bottom: 0in; font-size: 10pt; font-family: Aptos, sans-serif; color: rgb(25, 25, 25);'; runTest(rules, [ { @@ -150,7 +150,10 @@ describe('removeListParagraphMargins', () => { text: 'margin: 0in 0in 0in 0.5in; font-size: 10pt; font-family: Aptos, sans-serif; color: rgb(25, 25, 25);', }, - { selectors: ['p.MsoListParagraph', 'div.MsoListParagraph'], text: noMarginListText }, + { + selectors: ['p.MsoListParagraph', 'div.MsoListParagraph'], + text: noHorizontalMarginListText, + }, { selectors: ['li.MsoListParagraphCxSpFirst'], text: @@ -158,7 +161,7 @@ describe('removeListParagraphMargins', () => { }, { selectors: ['p.MsoListParagraphCxSpFirst', 'div.MsoListParagraphCxSpFirst'], - text: noMarginListText, + text: noHorizontalMarginListText, }, { selectors: ['li.MsoListParagraphCxSpMiddle'], @@ -167,7 +170,7 @@ describe('removeListParagraphMargins', () => { }, { selectors: ['p.MsoListParagraphCxSpMiddle', 'div.MsoListParagraphCxSpMiddle'], - text: noMarginListText, + text: noHorizontalMarginListText, }, { selectors: ['li.MsoListParagraphCxSpLast'], @@ -176,7 +179,7 @@ describe('removeListParagraphMargins', () => { }, { selectors: ['p.MsoListParagraphCxSpLast', 'div.MsoListParagraphCxSpLast'], - text: noMarginListText, + text: noHorizontalMarginListText, }, { selectors: ['span.Heading1Char'], @@ -203,9 +206,44 @@ describe('removeListParagraphMargins', () => { { selectors: ['span.Normal'], text: 'margin: 5pt; color: black;' }, { selectors: ['p.MsoListParagraph'], - text: ' font-size: 11pt;', + text: 'margin-top: 0pt; margin-bottom: 0pt; font-size: 11pt;', }, { selectors: ['div.Section'], text: 'margin-right: 2pt; padding: 4pt;' }, ]); }); + + it('preserves top and bottom from two-, three-, and four-value shorthands', () => { + const rules: CssRule[] = [ + { + selectors: ['p.MsoListParagraph'], + text: 'margin: 1pt 2pt; color: red; margin: 3pt 4pt 5pt; margin: 6pt 7pt 8pt 9pt;', + }, + ]; + + runTest(rules, [ + { + selectors: ['p.MsoListParagraph'], + text: + 'margin-top: 1pt; margin-bottom: 1pt; color: red; margin-top: 3pt; margin-bottom: 5pt; margin-top: 6pt; margin-bottom: 8pt;', + }, + ]); + }); + + it('preserves CSS function values in shorthand margins', () => { + const rules: CssRule[] = [ + { + selectors: ['p.MsoListParagraph'], + text: + 'margin: calc(100% - 2px) 4px; margin: var(--top-margin, 1px 2px) 4px var(--bottom-margin, 3px 4px) 8px !important;', + }, + ]; + + runTest(rules, [ + { + selectors: ['p.MsoListParagraph'], + text: + 'margin-top: calc(100% - 2px); margin-bottom: calc(100% - 2px); margin-top: var(--top-margin, 1px 2px) !important; margin-bottom: var(--bottom-margin, 3px 4px) !important;', + }, + ]); + }); }); diff --git a/packages/roosterjs-content-model-plugins/test/paste/word/wordBorderParserTest.ts b/packages/roosterjs-content-model-plugins/test/paste/word/wordBorderParserTest.ts new file mode 100644 index 000000000000..6b73092d15c4 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/paste/word/wordBorderParserTest.ts @@ -0,0 +1,79 @@ +import { wordBorderParser } from '../../../lib/paste/WordDesktop/wordBorderParser'; +import type { ContentModelTableFormat, DomToModelContext } from 'roosterjs-content-model-types'; + +describe('wordBorderParser', () => { + let format: ContentModelTableFormat; + let element: HTMLElement; + let context: DomToModelContext; + + beforeEach(() => { + format = {}; + element = document.createElement('td'); + context = {} as any; + }); + + it('clears all border formats when style attribute has border none', () => { + format.borderTop = '1px solid'; + format.borderRight = '1px solid'; + format.borderBottom = '1px solid'; + format.borderLeft = '1px solid'; + element.setAttribute('style', 'width: 100px; border:none; padding: 0in 5.4pt 0in 5.4pt;'); + + wordBorderParser(format, element, context, {}); + + expect(format).toEqual({ + borderTop: 'none', + borderRight: 'none', + borderBottom: 'none', + borderLeft: 'none', + }); + }); + + it('clears side border formats when style attribute has side border none', () => { + format.borderTop = '1px solid'; + format.borderRight = '1px solid'; + format.borderBottom = '1px solid'; + format.borderLeft = '1px solid'; + element.setAttribute( + 'style', + 'border-top:none; border-right:1px solid red; border-bottom: none !important;' + ); + + wordBorderParser(format, element, context, {}); + + expect(format).toEqual({ + borderTop: 'none', + borderRight: '1px solid', + borderBottom: 'none', + borderLeft: '1px solid', + }); + }); + + it('respects later visible side border declarations after border none', () => { + format.borderTop = '1px solid'; + format.borderRight = '1px solid'; + format.borderBottom = '1px solid'; + format.borderLeft = '1px solid'; + element.setAttribute('style', 'border:none; border-top: 1px solid red;'); + + wordBorderParser(format, element, context, {}); + + expect(format).toEqual({ + borderTop: '1px solid', + borderRight: 'none', + borderBottom: 'none', + borderLeft: 'none', + }); + }); + + it('does not update border formats without border none in style attribute', () => { + format.borderTop = '1px solid'; + element.setAttribute('style', 'border-top: 1px solid red;'); + + wordBorderParser(format, element, context, {}); + + expect(format).toEqual({ + borderTop: '1px solid', + }); + }); +}); diff --git a/versions.json b/versions.json index 7a4de6f5c9bf..cb5e05a92834 100644 --- a/versions.json +++ b/versions.json @@ -1,6 +1,6 @@ { "react": "9.0.7", - "main": "9.60.0", + "main": "9.61.0", "legacyAdapter": "8.66.3", "overrides": {} } diff --git a/yarn.lock b/yarn.lock index 55093cbe34f9..e375451bd666 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2827,9 +2827,9 @@ fast-levenshtein@^2.0.6: integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= fast-uri@^3.0.1: - version "3.1.6" - resolved "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-uri/-/fast-uri-3.1.6.tgz#bfdd308ef763f835fed059f3c6c925f708e33e3a" - integrity sha1-v90wjvdj+DX+0Fnzxskl9wjjPjo= + version "3.1.8" + resolved "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-uri/-/fast-uri-3.1.8.tgz#f7db8d942e20eead3cfe93b0beeb4eb0169e938b" + integrity sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg== fastq@^1.6.0: version "1.20.1"