From 2739bd17f548fc667ff404fc8725fe9bd5f5be6c Mon Sep 17 00:00:00 2001 From: Sascha Greuel Date: Sat, 4 Jul 2026 21:13:41 +0200 Subject: [PATCH] Prevent inherited content color from being pasted Hook into the clipboard input transformation before CKEditor converts pasted HTML into model content. When browsers copy normal WSC text, they may serialize inherited --wcfContentText as an inline color style. CKEditor then treats that as an intentional font color and persists it in the submitted markup. Strip only color styles that match the current WSC content text color, while preserving other formatting such as bold text. Arbitrary external colors are intentionally left untouched because browser clipboard HTML does not distinguish inherited page color from intentional author formatting. --- .../src/woltlabpastefromoffice.ts | 162 +++++++++++++++++- 1 file changed, 159 insertions(+), 3 deletions(-) diff --git a/plugins/ckeditor5-woltlab-paste-from-office/src/woltlabpastefromoffice.ts b/plugins/ckeditor5-woltlab-paste-from-office/src/woltlabpastefromoffice.ts index 613e254..2c481ee 100644 --- a/plugins/ckeditor5-woltlab-paste-from-office/src/woltlabpastefromoffice.ts +++ b/plugins/ckeditor5-woltlab-paste-from-office/src/woltlabpastefromoffice.ts @@ -10,12 +10,24 @@ import { Plugin } from "@ckeditor/ckeditor5-core"; import { ClipboardContentInsertionEvent, + ClipboardInputTransformationEvent, ClipboardObserver, ClipboardPipeline, } from "@ckeditor/ckeditor5-clipboard"; -import { Model, ModelDocumentFragment } from "@ckeditor/ckeditor5-engine"; +import { + Model, + ModelDocumentFragment, + ViewDocumentFragment, + ViewElement, + ViewUpcastWriter, +} from "@ckeditor/ckeditor5-engine"; + +const INHERITED_TEXT_COLOR_VARIABLE = "--wcfContentText"; +const COLOR_STYLE = "color"; export class WoltlabPasteFromOffice extends Plugin { + #colorNormalizer?: CanvasRenderingContext2D | null; + static get pluginName() { return "WoltlabPasteFromOffice"; } @@ -23,6 +35,16 @@ export class WoltlabPasteFromOffice extends Plugin { init() { this.editor.editing.view.addObserver(ClipboardObserver); + this.editor.plugins + .get(ClipboardPipeline) + .on( + "inputTransformation", + (event, data) => { + this.#removeInheritedTextColor(data.content); + }, + { priority: "high" }, + ); + this.editor.plugins .get(ClipboardPipeline) .on("contentInsertion", (event, data) => { @@ -36,7 +58,7 @@ export class WoltlabPasteFromOffice extends Plugin { * Pasting from the web version of Excel can sometimes prepend a block of CSS * in front of the table. This block is added as a plain text paragraph that * needs to be removed. - * + * * This can be easily detected by checking if a paragraph followed by a table * is pasted and the paragraph contains certain strings that are unique to * MS office. @@ -45,7 +67,7 @@ export class WoltlabPasteFromOffice extends Plugin { documentFragment: ModelDocumentFragment, model: Model, ): boolean { - let range = model.createRangeIn(documentFragment); + const range = model.createRangeIn(documentFragment); if (documentFragment.childCount !== 2) { return false; } @@ -91,6 +113,140 @@ export class WoltlabPasteFromOffice extends Plugin { writer.remove(items[0]); }); } + + #removeInheritedTextColor(documentFragment: ViewDocumentFragment): void { + const colors = this.#getInheritedTextColors(); + if (colors.size === 0) { + return; + } + + const writer = new ViewUpcastWriter(documentFragment.document); + const range = writer.createRangeIn(documentFragment); + const elements = Array.from(range.getItems()).filter( + (item): item is ViewElement => item.is("element"), + ); + + for (const element of elements) { + const color = element.getStyle(COLOR_STYLE); + if (color === undefined) { + continue; + } + + const normalizedColor = this.#normalizeColor(color); + if (normalizedColor !== undefined && colors.has(normalizedColor)) { + writer.removeStyle(COLOR_STYLE, element); + } + } + } + + #getInheritedTextColors(): Set { + const colors = new Set(); + const editableElement = this.editor.ui.getEditableElement(); + + if (editableElement !== null) { + this.#addComputedTextColors(colors, editableElement); + } + + if (document.body !== null) { + this.#addComputedTextColors(colors, document.body); + } + + this.#addComputedTextColors(colors, document.documentElement); + this.#addStyleSheetTextColors(colors); + + return colors; + } + + #addComputedTextColors(colors: Set, element: HTMLElement): void { + const style = getComputedStyle(element); + + this.#addColor(colors, style.color); + this.#addColor( + colors, + style.getPropertyValue(INHERITED_TEXT_COLOR_VARIABLE), + ); + } + + #addStyleSheetTextColors(colors: Set): void { + for (const styleSheet of Array.from(document.styleSheets)) { + let cssRules: CSSRuleList; + + try { + cssRules = styleSheet.cssRules; + } catch { + continue; + } + + this.#addStyleSheetRuleTextColors(colors, cssRules); + } + } + + #addStyleSheetRuleTextColors( + colors: Set, + cssRules: CSSRuleList, + ): void { + for (const rule of Array.from(cssRules)) { + if ("style" in rule) { + this.#addColor( + colors, + ( + rule as CSSRule & { style: CSSStyleDeclaration } + ).style.getPropertyValue(INHERITED_TEXT_COLOR_VARIABLE), + ); + } + + if ("cssRules" in rule) { + this.#addStyleSheetRuleTextColors( + colors, + (rule as CSSRule & { cssRules: CSSRuleList }).cssRules, + ); + } + } + } + + #addColor(colors: Set, color: string): void { + const normalizedColor = this.#normalizeColor(color); + + if (normalizedColor !== undefined) { + colors.add(normalizedColor); + } + } + + #normalizeColor(color: string): string | undefined { + const trimmedColor = color.trim(); + if (trimmedColor === "" || trimmedColor.includes("var(")) { + return undefined; + } + + const context = this.#getColorNormalizer(); + if (context !== null) { + context.fillStyle = "#000001"; + context.fillStyle = trimmedColor; + + const normalizedColor = context.fillStyle.toLowerCase(); + if ( + normalizedColor !== "#000001" || + trimmedColor.replace(/\s/g, "").toLowerCase() === "rgb(0,0,1)" + ) { + return normalizedColor; + } + } + + return trimmedColor + .replace(/\s/g, "") + .replace(/^rgba\((\d+),(\d+),(\d+),(?:1|1\.0)\)$/i, "rgb($1,$2,$3)") + .toLowerCase(); + } + + #getColorNormalizer(): CanvasRenderingContext2D | null { + if (this.#colorNormalizer !== undefined) { + return this.#colorNormalizer; + } + + this.#colorNormalizer = document.createElement("canvas").getContext("2d"); + + return this.#colorNormalizer; + } } export default WoltlabPasteFromOffice;