From f98eaaff04434119945606629a31b310c97777b7 Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Tue, 5 May 2026 14:40:05 +0200 Subject: [PATCH 1/3] fix: preserve raw inline style values to avoid " encoding --- .changeset/olive-penguins-joke.md | 6 ++ packages/extension-highlight/src/highlight.ts | 32 ++++++- .../__tests__/font-family.spec.ts | 89 +++++++++++++++++++ .../__tests__/font-size.spec.ts | 35 ++++++++ .../__tests__/line-height.spec.ts | 35 ++++++++ .../src/font-family/font-family.ts | 32 ++++++- .../src/font-size/font-size.ts | 29 +++++- .../src/line-height/line-height.ts | 29 +++++- 8 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 .changeset/olive-penguins-joke.md create mode 100644 packages/extension-text-style/__tests__/font-family.spec.ts create mode 100644 packages/extension-text-style/__tests__/font-size.spec.ts create mode 100644 packages/extension-text-style/__tests__/line-height.spec.ts diff --git a/.changeset/olive-penguins-joke.md b/.changeset/olive-penguins-joke.md new file mode 100644 index 0000000000..0e23f9f8f3 --- /dev/null +++ b/.changeset/olive-penguins-joke.md @@ -0,0 +1,6 @@ +--- +'@tiptap/extension-text-style': patch +'@tiptap/extension-highlight': patch +--- + +Fix `"` HTML entity encoding in `getHTML()` output for `font-family`, `font-size`, `line-height`, and `highlight` background-color attributes by parsing the raw inline `style` attribute instead of CSSOM-canonicalized values (#7016) diff --git a/packages/extension-highlight/src/highlight.ts b/packages/extension-highlight/src/highlight.ts index c1f1d4d9c9..67dec3350c 100644 --- a/packages/extension-highlight/src/highlight.ts +++ b/packages/extension-highlight/src/highlight.ts @@ -72,7 +72,37 @@ export const Highlight = Mark.create({ return { color: { default: null, - parseHTML: element => element.getAttribute('data-color') || element.style.backgroundColor, + parseHTML: element => { + // Prefer `data-color` set by our own `renderHTML` since it + // round-trips losslessly. Otherwise parse the raw inline + // `style` attribute so we preserve the original color format + // (e.g. `#rrggbb`) instead of the canonicalized `rgb(...)` + // value returned by `element.style.backgroundColor`. + const dataColor = element.getAttribute('data-color') + if (dataColor) { + return dataColor + } + + const styleAttr = element.getAttribute('style') + if (styleAttr) { + const decls = styleAttr + .split(';') + .map(s => s.trim()) + .filter(Boolean) + for (let i = decls.length - 1; i >= 0; i -= 1) { + const parts = decls[i].split(':') + if (parts.length >= 2) { + const prop = parts[0].trim().toLowerCase() + const val = parts.slice(1).join(':').trim() + if (prop === 'background-color') { + return val + } + } + } + } + + return element.style.backgroundColor + }, renderHTML: attributes => { if (!attributes.color) { return {} diff --git a/packages/extension-text-style/__tests__/font-family.spec.ts b/packages/extension-text-style/__tests__/font-family.spec.ts new file mode 100644 index 0000000000..0e340707bb --- /dev/null +++ b/packages/extension-text-style/__tests__/font-family.spec.ts @@ -0,0 +1,89 @@ +import { FontFamily } from '@tiptap/extension-text-style' +import { describe, expect, it } from 'vitest' + +const ext: any = (FontFamily as any).configure() +const globalAttrs = ext.config.addGlobalAttributes && ext.config.addGlobalAttributes.call(ext)[0] +const { parseHTML } = globalAttrs.attributes.fontFamily + +describe('fontFamily parseHTML', () => { + it('preserves unquoted multi-word values without canonicalizing to quoted form', () => { + const el = document.createElement('span') + el.setAttribute('style', 'font-family: Comic Sans MS, Comic Sans') + + const parsed = parseHTML(el) + + expect(parsed).toBe('Comic Sans MS, Comic Sans') + }) + + it('preserves single-quoted values without converting to double quotes', () => { + const el = document.createElement('span') + el.setAttribute('style', "font-family: 'Exo 2'") + + const parsed = parseHTML(el) + + expect(parsed).toBe("'Exo 2'") + }) + + it('preserves double-quoted values as-is', () => { + const el = document.createElement('span') + el.setAttribute('style', 'font-family: "Open Sans"') + + const parsed = parseHTML(el) + + expect(parsed).toBe('"Open Sans"') + }) + + it('parses simple single-word values', () => { + const el = document.createElement('span') + el.setAttribute('style', 'font-family: Inter') + + const parsed = parseHTML(el) + + expect(parsed).toBe('Inter') + }) + + it('parses generic keyword values', () => { + const el = document.createElement('span') + el.setAttribute('style', 'font-family: serif') + + const parsed = parseHTML(el) + + expect(parsed).toBe('serif') + }) + + it('preserves CSS variable values', () => { + const el = document.createElement('span') + el.setAttribute('style', 'font-family: var(--title-font-family)') + + const parsed = parseHTML(el) + + expect(parsed).toBe('var(--title-font-family)') + }) + + it('returns the last declaration when font-family is declared multiple times', () => { + const el = document.createElement('span') + el.setAttribute('style', 'font-family: Inter; font-family: Arial') + + const parsed = parseHTML(el) + + expect(parsed).toBe('Arial') + }) + + it('ignores other style declarations and only returns font-family', () => { + const el = document.createElement('span') + el.setAttribute('style', 'color: red; font-family: Inter; font-size: 16px') + + const parsed = parseHTML(el) + + expect(parsed).toBe('Inter') + }) + + it('is case-insensitive on the property name', () => { + const el = document.createElement('span') + el.setAttribute('style', 'FONT-FAMILY: Inter') + + const parsed = parseHTML(el) + + expect(parsed).toBe('Inter') + }) +}) diff --git a/packages/extension-text-style/__tests__/font-size.spec.ts b/packages/extension-text-style/__tests__/font-size.spec.ts new file mode 100644 index 0000000000..a8bb3e7358 --- /dev/null +++ b/packages/extension-text-style/__tests__/font-size.spec.ts @@ -0,0 +1,35 @@ +import { FontSize } from '@tiptap/extension-text-style' +import { describe, expect, it } from 'vitest' + +const ext: any = (FontSize as any).configure() +const globalAttrs = ext.config.addGlobalAttributes && ext.config.addGlobalAttributes.call(ext)[0] +const { parseHTML } = globalAttrs.attributes.fontSize + +describe('fontSize parseHTML', () => { + it('parses simple inline style', () => { + const el = document.createElement('span') + el.setAttribute('style', 'font-size: 16px') + + const parsed = parseHTML(el) + + expect(parsed).toBe('16px') + }) + + it('returns the last declaration when font-size is declared multiple times', () => { + const el = document.createElement('span') + el.setAttribute('style', 'font-size: 12px; font-size: 18px') + + const parsed = parseHTML(el) + + expect(parsed).toBe('18px') + }) + + it('ignores other style declarations and only returns font-size', () => { + const el = document.createElement('span') + el.setAttribute('style', 'color: red; font-size: 1.5em; line-height: 1.2') + + const parsed = parseHTML(el) + + expect(parsed).toBe('1.5em') + }) +}) diff --git a/packages/extension-text-style/__tests__/line-height.spec.ts b/packages/extension-text-style/__tests__/line-height.spec.ts new file mode 100644 index 0000000000..f6c4cda939 --- /dev/null +++ b/packages/extension-text-style/__tests__/line-height.spec.ts @@ -0,0 +1,35 @@ +import { LineHeight } from '@tiptap/extension-text-style' +import { describe, expect, it } from 'vitest' + +const ext: any = (LineHeight as any).configure() +const globalAttrs = ext.config.addGlobalAttributes && ext.config.addGlobalAttributes.call(ext)[0] +const { parseHTML } = globalAttrs.attributes.lineHeight + +describe('lineHeight parseHTML', () => { + it('parses simple inline style', () => { + const el = document.createElement('span') + el.setAttribute('style', 'line-height: 1.5') + + const parsed = parseHTML(el) + + expect(parsed).toBe('1.5') + }) + + it('returns the last declaration when line-height is declared multiple times', () => { + const el = document.createElement('span') + el.setAttribute('style', 'line-height: 1.2; line-height: 2') + + const parsed = parseHTML(el) + + expect(parsed).toBe('2') + }) + + it('ignores other style declarations and only returns line-height', () => { + const el = document.createElement('span') + el.setAttribute('style', 'color: red; line-height: 24px; font-size: 16px') + + const parsed = parseHTML(el) + + expect(parsed).toBe('24px') + }) +}) diff --git a/packages/extension-text-style/src/font-family/font-family.ts b/packages/extension-text-style/src/font-family/font-family.ts index a96bf64da5..145b304eed 100644 --- a/packages/extension-text-style/src/font-family/font-family.ts +++ b/packages/extension-text-style/src/font-family/font-family.ts @@ -56,7 +56,37 @@ export const FontFamily = Extension.create({ attributes: { fontFamily: { default: null, - parseHTML: element => element.style.fontFamily, + parseHTML: element => { + // Prefer the raw inline `style` attribute so we preserve + // the original format (e.g. unquoted or single-quoted + // multi-word names) instead of the canonicalized value + // returned by `element.style.fontFamily`, which forces + // double quotes that then get HTML-encoded to `"` + // when the style attribute is serialized. + // When nested spans are merged the style attribute may + // contain multiple `font-family:` declarations + // (parent;child). We should pick the last declaration so + // the child's font-family takes priority. + const styleAttr = element.getAttribute('style') + if (styleAttr) { + const decls = styleAttr + .split(';') + .map(s => s.trim()) + .filter(Boolean) + for (let i = decls.length - 1; i >= 0; i -= 1) { + const parts = decls[i].split(':') + if (parts.length >= 2) { + const prop = parts[0].trim().toLowerCase() + const val = parts.slice(1).join(':').trim() + if (prop === 'font-family') { + return val + } + } + } + } + + return element.style.fontFamily + }, renderHTML: attributes => { if (!attributes.fontFamily) { return {} diff --git a/packages/extension-text-style/src/font-size/font-size.ts b/packages/extension-text-style/src/font-size/font-size.ts index 21c3e135d1..b179b05733 100644 --- a/packages/extension-text-style/src/font-size/font-size.ts +++ b/packages/extension-text-style/src/font-size/font-size.ts @@ -56,7 +56,34 @@ export const FontSize = Extension.create({ attributes: { fontSize: { default: null, - parseHTML: element => element.style.fontSize, + parseHTML: element => { + // Prefer the raw inline `style` attribute so we preserve + // the original format instead of the canonicalized value + // returned by `element.style.fontSize`. + // When nested spans are merged the style attribute may + // contain multiple `font-size:` declarations + // (parent;child). We should pick the last declaration so + // the child's font-size takes priority. + const styleAttr = element.getAttribute('style') + if (styleAttr) { + const decls = styleAttr + .split(';') + .map(s => s.trim()) + .filter(Boolean) + for (let i = decls.length - 1; i >= 0; i -= 1) { + const parts = decls[i].split(':') + if (parts.length >= 2) { + const prop = parts[0].trim().toLowerCase() + const val = parts.slice(1).join(':').trim() + if (prop === 'font-size') { + return val + } + } + } + } + + return element.style.fontSize + }, renderHTML: attributes => { if (!attributes.fontSize) { return {} diff --git a/packages/extension-text-style/src/line-height/line-height.ts b/packages/extension-text-style/src/line-height/line-height.ts index 95d3b1eefd..6ba1d50804 100644 --- a/packages/extension-text-style/src/line-height/line-height.ts +++ b/packages/extension-text-style/src/line-height/line-height.ts @@ -56,7 +56,34 @@ export const LineHeight = Extension.create({ attributes: { lineHeight: { default: null, - parseHTML: element => element.style.lineHeight, + parseHTML: element => { + // Prefer the raw inline `style` attribute so we preserve + // the original format instead of the canonicalized value + // returned by `element.style.lineHeight`. + // When nested spans are merged the style attribute may + // contain multiple `line-height:` declarations + // (parent;child). We should pick the last declaration so + // the child's line-height takes priority. + const styleAttr = element.getAttribute('style') + if (styleAttr) { + const decls = styleAttr + .split(';') + .map(s => s.trim()) + .filter(Boolean) + for (let i = decls.length - 1; i >= 0; i -= 1) { + const parts = decls[i].split(':') + if (parts.length >= 2) { + const prop = parts[0].trim().toLowerCase() + const val = parts.slice(1).join(':').trim() + if (prop === 'line-height') { + return val + } + } + } + } + + return element.style.lineHeight + }, renderHTML: attributes => { if (!attributes.lineHeight) { return {} From d28d7505efac008307b8fd52c3b10d5052a11f53 Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Tue, 5 May 2026 15:42:00 +0200 Subject: [PATCH 2/3] refactor: extract getStyleProperty utility to @tiptap/core --- .../core/__tests__/getStyleProperty.spec.ts | 52 +++++++++++++++++++ .../core/src/utilities/getStyleProperty.ts | 52 +++++++++++++++++++ packages/core/src/utilities/index.ts | 1 + packages/extension-highlight/src/highlight.ts | 41 ++++----------- .../src/background-color/background-color.ts | 32 +++--------- .../extension-text-style/src/color/color.ts | 32 +++--------- .../src/font-family/font-family.ts | 38 +++----------- .../src/font-size/font-size.ts | 34 ++---------- .../src/line-height/line-height.ts | 34 ++---------- 9 files changed, 142 insertions(+), 174 deletions(-) create mode 100644 packages/core/__tests__/getStyleProperty.spec.ts create mode 100644 packages/core/src/utilities/getStyleProperty.ts diff --git a/packages/core/__tests__/getStyleProperty.spec.ts b/packages/core/__tests__/getStyleProperty.spec.ts new file mode 100644 index 0000000000..700366b098 --- /dev/null +++ b/packages/core/__tests__/getStyleProperty.spec.ts @@ -0,0 +1,52 @@ +import { getStyleProperty } from '@tiptap/core' +import { describe, expect, it } from 'vitest' + +describe('getStyleProperty', () => { + it('returns null when the element has no style attribute', () => { + const el = document.createElement('span') + + expect(getStyleProperty(el, 'font-family')).toBeNull() + }) + + it('returns null when the requested property is not declared', () => { + const el = document.createElement('span') + el.setAttribute('style', 'color: red') + + expect(getStyleProperty(el, 'font-family')).toBeNull() + }) + + it('returns the raw value preserving original formatting', () => { + const el = document.createElement('span') + el.setAttribute('style', "font-family: 'Comic Sans MS', cursive") + + expect(getStyleProperty(el, 'font-family')).toBe("'Comic Sans MS', cursive") + }) + + it('returns the last declaration when the property is declared multiple times', () => { + const el = document.createElement('span') + el.setAttribute('style', 'color: red; color: blue') + + expect(getStyleProperty(el, 'color')).toBe('blue') + }) + + it('matches the property name case-insensitively', () => { + const el = document.createElement('span') + el.setAttribute('style', 'FONT-FAMILY: Inter') + + expect(getStyleProperty(el, 'font-family')).toBe('Inter') + }) + + it('preserves colons that appear inside the value (e.g. URLs)', () => { + const el = document.createElement('span') + el.setAttribute('style', "background-image: url('https://example.com/img.png')") + + expect(getStyleProperty(el, 'background-image')).toBe("url('https://example.com/img.png')") + }) + + it('ignores trailing semicolons and extra whitespace', () => { + const el = document.createElement('span') + el.setAttribute('style', ' font-size: 16px ; ') + + expect(getStyleProperty(el, 'font-size')).toBe('16px') + }) +}) diff --git a/packages/core/src/utilities/getStyleProperty.ts b/packages/core/src/utilities/getStyleProperty.ts new file mode 100644 index 0000000000..8c6652ccc8 --- /dev/null +++ b/packages/core/src/utilities/getStyleProperty.ts @@ -0,0 +1,52 @@ +/** + * Read a CSS property value directly from an element's raw inline `style` + * attribute, bypassing the CSSOM (e.g. `element.style.fontFamily`) which + * canonicalizes values and can change formatting. The original format is + * preserved (quotes, hex vs rgb, etc.). + * + * When a property is declared more than once, the last declaration wins — + * this matches CSS cascade order and is useful when nested spans are merged + * and the child's value should take priority. + * + * Property name comparison is case-insensitive. + * + * @param element - The element whose `style` attribute should be read. + * @param propertyName - The CSS property name (e.g. `font-family`). + * @returns The raw value string, or `null` if the property is not present. + * + * @example + * ```ts + * parseHTML: element => getStyleProperty(element, 'font-family') + * ``` + */ +export function getStyleProperty(element: HTMLElement, propertyName: string): string | null { + const styleAttr = element.getAttribute('style') + + if (!styleAttr) { + return null + } + + const decls = styleAttr + .split(';') + .map(decl => decl.trim()) + .filter(Boolean) + + const target = propertyName.toLowerCase() + + for (let i = decls.length - 1; i >= 0; i -= 1) { + const decl = decls[i] + const colonIndex = decl.indexOf(':') + + if (colonIndex === -1) { + continue + } + + const prop = decl.slice(0, colonIndex).trim().toLowerCase() + + if (prop === target) { + return decl.slice(colonIndex + 1).trim() + } + } + + return null +} diff --git a/packages/core/src/utilities/index.ts b/packages/core/src/utilities/index.ts index 1202126086..2ceee61c1b 100644 --- a/packages/core/src/utilities/index.ts +++ b/packages/core/src/utilities/index.ts @@ -6,6 +6,7 @@ export * from './elementFromString.js' export * from './escapeForRegEx.js' export * from './findDuplicates.js' export * from './fromString.js' +export * from './getStyleProperty.js' export * from './htmlEntities.js' export * from './isAndroid.js' export * from './isEmptyObject.js' diff --git a/packages/extension-highlight/src/highlight.ts b/packages/extension-highlight/src/highlight.ts index 67dec3350c..f8a28ef744 100644 --- a/packages/extension-highlight/src/highlight.ts +++ b/packages/extension-highlight/src/highlight.ts @@ -1,4 +1,4 @@ -import { Mark, markInputRule, markPasteRule, mergeAttributes } from '@tiptap/core' +import { getStyleProperty, Mark, markInputRule, markPasteRule, mergeAttributes } from '@tiptap/core' export interface HighlightOptions { /** @@ -72,37 +72,14 @@ export const Highlight = Mark.create({ return { color: { default: null, - parseHTML: element => { - // Prefer `data-color` set by our own `renderHTML` since it - // round-trips losslessly. Otherwise parse the raw inline - // `style` attribute so we preserve the original color format - // (e.g. `#rrggbb`) instead of the canonicalized `rgb(...)` - // value returned by `element.style.backgroundColor`. - const dataColor = element.getAttribute('data-color') - if (dataColor) { - return dataColor - } - - const styleAttr = element.getAttribute('style') - if (styleAttr) { - const decls = styleAttr - .split(';') - .map(s => s.trim()) - .filter(Boolean) - for (let i = decls.length - 1; i >= 0; i -= 1) { - const parts = decls[i].split(':') - if (parts.length >= 2) { - const prop = parts[0].trim().toLowerCase() - const val = parts.slice(1).join(':').trim() - if (prop === 'background-color') { - return val - } - } - } - } - - return element.style.backgroundColor - }, + // Prefer `data-color` (set by our own `renderHTML`) for lossless + // round-trips. Otherwise parse the raw inline `style` attribute so + // the original color format (e.g. `#rrggbb`) is preserved instead of + // the canonicalized `rgb(...)` value from `element.style.backgroundColor`. + parseHTML: element => + element.getAttribute('data-color') || + getStyleProperty(element, 'background-color') || + element.style.backgroundColor, renderHTML: attributes => { if (!attributes.color) { return {} diff --git a/packages/extension-text-style/src/background-color/background-color.ts b/packages/extension-text-style/src/background-color/background-color.ts index cf082b20a2..32573ffbec 100644 --- a/packages/extension-text-style/src/background-color/background-color.ts +++ b/packages/extension-text-style/src/background-color/background-color.ts @@ -1,6 +1,6 @@ import '../text-style/index.js' -import { Extension } from '@tiptap/core' +import { Extension, getStyleProperty } from '@tiptap/core' export type BackgroundColorOptions = { /** @@ -58,31 +58,11 @@ export const BackgroundColor = Extension.create({ backgroundColor: { default: null, parseHTML: element => { - // Prefer the raw inline `style` attribute so we preserve - // the original format (e.g. `#rrggbb`) instead of the - // computed `rgb(...)` value returned by `element.style.backgroundColor`. - // When nested spans are merged the style attribute may contain - // multiple `background-color:` declarations (parent;child). We should pick - // the last declaration so the child's background color takes priority. - const styleAttr = element.getAttribute('style') - if (styleAttr) { - const decls = styleAttr - .split(';') - .map(s => s.trim()) - .filter(Boolean) - for (let i = decls.length - 1; i >= 0; i -= 1) { - const parts = decls[i].split(':') - if (parts.length >= 2) { - const prop = parts[0].trim().toLowerCase() - const val = parts.slice(1).join(':').trim() - if (prop === 'background-color') { - return val.replace(/['"]+/g, '') - } - } - } - } - - return element.style.backgroundColor?.replace(/['"]+/g, '') + // Prefer the raw inline `style` attribute so we preserve the + // original format (e.g. `#rrggbb`) instead of the canonicalized + // `rgb(...)` value returned by `element.style.backgroundColor`. + const value = getStyleProperty(element, 'background-color') ?? element.style.backgroundColor + return value?.replace(/['"]+/g, '') }, renderHTML: attributes => { if (!attributes.backgroundColor) { diff --git a/packages/extension-text-style/src/color/color.ts b/packages/extension-text-style/src/color/color.ts index 5940430da0..907308a4df 100644 --- a/packages/extension-text-style/src/color/color.ts +++ b/packages/extension-text-style/src/color/color.ts @@ -1,6 +1,6 @@ import '../text-style/index.js' -import { Extension } from '@tiptap/core' +import { Extension, getStyleProperty } from '@tiptap/core' export type ColorOptions = { /** @@ -58,31 +58,11 @@ export const Color = Extension.create({ color: { default: null, parseHTML: element => { - // Prefer the raw inline `style` attribute so we preserve - // the original format (e.g. `#rrggbb`) instead of the - // computed `rgb(...)` value returned by `element.style.color`. - // When nested spans are merged the style attribute may contain - // multiple `color:` declarations (parent;child). We should pick - // the last declaration so the child's color takes priority. - const styleAttr = element.getAttribute('style') - if (styleAttr) { - const decls = styleAttr - .split(';') - .map(s => s.trim()) - .filter(Boolean) - for (let i = decls.length - 1; i >= 0; i -= 1) { - const parts = decls[i].split(':') - if (parts.length >= 2) { - const prop = parts[0].trim().toLowerCase() - const val = parts.slice(1).join(':').trim() - if (prop === 'color') { - return val.replace(/['"]+/g, '') - } - } - } - } - - return element.style.color?.replace(/['"]+/g, '') + // Prefer the raw inline `style` attribute so we preserve the + // original format (e.g. `#rrggbb`) instead of the canonicalized + // `rgb(...)` value returned by `element.style.color`. + const value = getStyleProperty(element, 'color') ?? element.style.color + return value?.replace(/['"]+/g, '') }, renderHTML: attributes => { if (!attributes.color) { diff --git a/packages/extension-text-style/src/font-family/font-family.ts b/packages/extension-text-style/src/font-family/font-family.ts index 145b304eed..9fd29a4911 100644 --- a/packages/extension-text-style/src/font-family/font-family.ts +++ b/packages/extension-text-style/src/font-family/font-family.ts @@ -1,6 +1,6 @@ import '../text-style/index.js' -import { Extension } from '@tiptap/core' +import { Extension, getStyleProperty } from '@tiptap/core' export type FontFamilyOptions = { /** @@ -56,37 +56,11 @@ export const FontFamily = Extension.create({ attributes: { fontFamily: { default: null, - parseHTML: element => { - // Prefer the raw inline `style` attribute so we preserve - // the original format (e.g. unquoted or single-quoted - // multi-word names) instead of the canonicalized value - // returned by `element.style.fontFamily`, which forces - // double quotes that then get HTML-encoded to `"` - // when the style attribute is serialized. - // When nested spans are merged the style attribute may - // contain multiple `font-family:` declarations - // (parent;child). We should pick the last declaration so - // the child's font-family takes priority. - const styleAttr = element.getAttribute('style') - if (styleAttr) { - const decls = styleAttr - .split(';') - .map(s => s.trim()) - .filter(Boolean) - for (let i = decls.length - 1; i >= 0; i -= 1) { - const parts = decls[i].split(':') - if (parts.length >= 2) { - const prop = parts[0].trim().toLowerCase() - const val = parts.slice(1).join(':').trim() - if (prop === 'font-family') { - return val - } - } - } - } - - return element.style.fontFamily - }, + // Prefer the raw inline `style` attribute so unquoted or + // single-quoted multi-word names are preserved instead of being + // canonicalized by `element.style.fontFamily`, which forces double + // quotes that then get HTML-encoded to `"` on serialization. + parseHTML: element => getStyleProperty(element, 'font-family') ?? element.style.fontFamily, renderHTML: attributes => { if (!attributes.fontFamily) { return {} diff --git a/packages/extension-text-style/src/font-size/font-size.ts b/packages/extension-text-style/src/font-size/font-size.ts index b179b05733..669202ad6e 100644 --- a/packages/extension-text-style/src/font-size/font-size.ts +++ b/packages/extension-text-style/src/font-size/font-size.ts @@ -1,6 +1,6 @@ import '../text-style/index.js' -import { Extension } from '@tiptap/core' +import { Extension, getStyleProperty } from '@tiptap/core' export type FontSizeOptions = { /** @@ -56,34 +56,10 @@ export const FontSize = Extension.create({ attributes: { fontSize: { default: null, - parseHTML: element => { - // Prefer the raw inline `style` attribute so we preserve - // the original format instead of the canonicalized value - // returned by `element.style.fontSize`. - // When nested spans are merged the style attribute may - // contain multiple `font-size:` declarations - // (parent;child). We should pick the last declaration so - // the child's font-size takes priority. - const styleAttr = element.getAttribute('style') - if (styleAttr) { - const decls = styleAttr - .split(';') - .map(s => s.trim()) - .filter(Boolean) - for (let i = decls.length - 1; i >= 0; i -= 1) { - const parts = decls[i].split(':') - if (parts.length >= 2) { - const prop = parts[0].trim().toLowerCase() - const val = parts.slice(1).join(':').trim() - if (prop === 'font-size') { - return val - } - } - } - } - - return element.style.fontSize - }, + // Prefer the raw inline `style` attribute so the original format + // is preserved instead of the canonicalized value returned by + // `element.style.fontSize`. + parseHTML: element => getStyleProperty(element, 'font-size') ?? element.style.fontSize, renderHTML: attributes => { if (!attributes.fontSize) { return {} diff --git a/packages/extension-text-style/src/line-height/line-height.ts b/packages/extension-text-style/src/line-height/line-height.ts index 6ba1d50804..f7012be5fd 100644 --- a/packages/extension-text-style/src/line-height/line-height.ts +++ b/packages/extension-text-style/src/line-height/line-height.ts @@ -1,6 +1,6 @@ import '../text-style/index.js' -import { Extension } from '@tiptap/core' +import { Extension, getStyleProperty } from '@tiptap/core' export type LineHeightOptions = { /** @@ -56,34 +56,10 @@ export const LineHeight = Extension.create({ attributes: { lineHeight: { default: null, - parseHTML: element => { - // Prefer the raw inline `style` attribute so we preserve - // the original format instead of the canonicalized value - // returned by `element.style.lineHeight`. - // When nested spans are merged the style attribute may - // contain multiple `line-height:` declarations - // (parent;child). We should pick the last declaration so - // the child's line-height takes priority. - const styleAttr = element.getAttribute('style') - if (styleAttr) { - const decls = styleAttr - .split(';') - .map(s => s.trim()) - .filter(Boolean) - for (let i = decls.length - 1; i >= 0; i -= 1) { - const parts = decls[i].split(':') - if (parts.length >= 2) { - const prop = parts[0].trim().toLowerCase() - const val = parts.slice(1).join(':').trim() - if (prop === 'line-height') { - return val - } - } - } - } - - return element.style.lineHeight - }, + // Prefer the raw inline `style` attribute so the original format + // is preserved instead of the canonicalized value returned by + // `element.style.lineHeight`. + parseHTML: element => getStyleProperty(element, 'line-height') ?? element.style.lineHeight, renderHTML: attributes => { if (!attributes.lineHeight) { return {} From 561da5ed6ec17f2b2614ac62563e1c4f60f59e03 Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Tue, 5 May 2026 15:56:59 +0200 Subject: [PATCH 3/3] chore: update changeset to include @tiptap/core --- .changeset/olive-penguins-joke.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/olive-penguins-joke.md b/.changeset/olive-penguins-joke.md index 0e23f9f8f3..4f06735bd8 100644 --- a/.changeset/olive-penguins-joke.md +++ b/.changeset/olive-penguins-joke.md @@ -1,6 +1,7 @@ --- +'@tiptap/core': patch '@tiptap/extension-text-style': patch '@tiptap/extension-highlight': patch --- -Fix `"` HTML entity encoding in `getHTML()` output for `font-family`, `font-size`, `line-height`, and `highlight` background-color attributes by parsing the raw inline `style` attribute instead of CSSOM-canonicalized values (#7016) +Fix `"` HTML entity encoding in `getHTML()` output for inline style attributes. Adds a `getStyleProperty` utility to `@tiptap/core` and migrates `Color`, `BackgroundColor`, `FontFamily`, `FontSize`, `LineHeight`, and `Highlight` extensions to use it (#7016)