[0] = {},
+) {
+ const editor = BlockNoteEditor.create(options);
+ editors.push(editor);
+ return editor;
+}
+
+function parseOfficeHTML(html: string) {
+ return createEditor().tryParseHTMLToBlocks(normalizeOfficeHTML(html));
+}
+
+afterEach(() => {
+ for (const editor of editors.splice(0)) {
+ editor._tiptapEditor.destroy();
+ }
+});
+
+function wordItem(text: string, level = 1, marker = "·", list = "l0") {
+ return `${marker} ${text}
`;
+}
+
+describe("Office clipboard HTML", () => {
+ // Clipboard samples from CKEditor 5's paste-from-office/tests/_data at
+ // https://github.com/ckeditor/ckeditor5/tree/e7c72a94f7cc6be9dff5ae0bfb81af3eaa42f62d/packages/ckeditor5-paste-from-office/tests/_data
+ // Word captures omit the generated head except for source identification.
+ // Keep fragment whitespace intact. Snapshots omit only generated block IDs.
+ // Copyright (c) 2003-2026, CKSource Holding sp. z o.o.
+ // Upstream license notice (GPL-2.0-or-later or commercial):
+ // https://github.com/ckeditor/ckeditor5/blob/e7c72a94f7cc6be9dff5ae0bfb81af3eaa42f62d/packages/ckeditor5/LICENSE.md
+ it.each([
+ [
+ "Word formatting",
+ // basic-styles/multiple-styles-multiline/input.word2016.html
+ `
+
+
+Line bold and italics
+
+Line foo bar
+
+Third line styling,
+ space on end
+
+
+`,
+ ],
+ [
+ "Word mixed lists",
+ // list/mixed-elements/input.word.html
+ `
+
+·
+One
+Asdlkfj alksdfj
+
+·
+Two
+Asdlkfjalksdfj lkasjdf lkasj dflka sdf
+
+·
+Three
+Alskdfj alksdfj lkasj df
+
+
+
+ - Three sub one Asldkfjalksdfj
+laksjdf
+ - Three sub two Alskdfjlaksjfd
+lkasd f
+
+
+
+·
+Four
+Asldkfjalkfdj lkasd
+
+·
+Five
+Alskdfj laksdfjlak jd
+
+·
+Six
+Alskdfj laksjdf lak
+
+o Six sub one Alsdkfj
+
+·
+Seven
+asdfasdfadf alksd flaksjdf laksjd alksjdf askljd f
+
+
+`,
+ ],
+ [
+ "Google Docs paragraphs",
+ // paste-from-google-docs/br-paragraph/simple-paragraphs/input.html
+ `
A
B
+`,
+ ],
+ [
+ "Google Docs mixed lists",
+ // paste-from-google-docs/lists/mixed-list/input.html
+ `1
2
3
+`,
+ ],
+ ])("parses captured clipboard HTML: %s", (_name, html) => {
+ const blocks = parseOfficeHTML(html);
+
+ function withoutIds(blocks: ReturnType): object[] {
+ return blocks.map(({ id: _id, children, ...block }) => ({
+ ...block,
+ children: withoutIds(children),
+ }));
+ }
+
+ expect(withoutIds(blocks)).toMatchSnapshot();
+ });
+
+ it("reconstructs Word lists, retaining formatting and nesting", () => {
+ const blocks = parseOfficeHTML(
+ wordItem("First").replace(
+ 'style="mso-list:',
+ 'style="text-align:center;mso-list:',
+ ) +
+ wordItem("Nested", 2, "1.") +
+ wordItem("Second"),
+ );
+ expect(blocks).toMatchObject([
+ {
+ type: "bulletListItem",
+ props: { textAlignment: "center" },
+ content: [{ text: "First", styles: { bold: true } }],
+ children: [
+ {
+ type: "numberedListItem",
+ content: [{ text: "Nested" }],
+ },
+ ],
+ },
+ { type: "bulletListItem", content: [{ text: "Second" }], children: [] },
+ ]);
+ expect(blocks).toHaveLength(2);
+ });
+
+ it("separates Word lists across paragraphs, list IDs and marker types", () => {
+ const blocks = parseOfficeHTML(
+ wordItem("One", 1, "4.") +
+ "Between
" +
+ wordItem("Two", 1, "7.") +
+ wordItem("Bullet") +
+ wordItem("New list", 1, "2.", "l1") +
+ wordItem("Another list", 2, "1.", "l2"),
+ );
+ expect(blocks.map((block) => block.type)).toEqual([
+ "numberedListItem",
+ "paragraph",
+ "numberedListItem",
+ "bulletListItem",
+ "numberedListItem",
+ "numberedListItem",
+ ]);
+ expect(blocks).toHaveLength(6);
+ expect(blocks[4].children).toEqual([]);
+ });
+
+ it("removes Word paragraph-end placeholders without removing empty paragraphs", () => {
+ const blocks = parseOfficeHTML(
+ 'Text
',
+ );
+ expect(blocks).toMatchObject([
+ { type: "paragraph", content: [{ text: "Text" }] },
+ { type: "paragraph", content: [] },
+ ]);
+ expect(blocks).toHaveLength(2);
+ });
+
+ it("parses nested numbered lists", () => {
+ const blocks = parseOfficeHTML(
+ wordItem("Parent", 1, "1.") +
+ wordItem("Child", 2, "1.") +
+ wordItem("Next", 1, "2."),
+ );
+ expect(blocks).toMatchObject([
+ {
+ type: "numberedListItem",
+ content: [{ text: "Parent" }],
+ children: [{ type: "numberedListItem", content: [{ text: "Child" }] }],
+ },
+ { type: "numberedListItem", content: [{ text: "Next" }] },
+ ]);
+ expect(blocks).toHaveLength(2);
+ });
+
+ it("recognizes parenthesized numbers and retains unsupported Word labels", () => {
+ const blocks = parseOfficeHTML(
+ wordItem("Number", 1, "(3)") +
+ wordItem("Multilevel", 1, "1.2.") +
+ wordItem("Unknown", 1, "Section A:") +
+ wordItem("Roman", 1, "iv.") +
+ wordItem("Letter", 1, "c."),
+ );
+ expect(blocks).toMatchObject([
+ {
+ type: "numberedListItem",
+ content: [{ text: "Number" }],
+ },
+ { type: "paragraph", content: [{ text: "1.2.\u00a0Multilevel" }] },
+ { type: "paragraph", content: [{ text: "Section A:\u00a0Unknown" }] },
+ { type: "paragraph", content: [{ text: "iv.\u00a0Roman" }] },
+ { type: "paragraph", content: [{ text: "c.\u00a0Letter" }] },
+ ]);
+ });
+
+ it("does not move intervening text into a Word list", () => {
+ const blocks = parseOfficeHTML(
+ wordItem("First") + "Between" + wordItem("Second"),
+ );
+ expect(blocks).toMatchObject([
+ { type: "bulletListItem", content: [{ text: "First" }] },
+ { type: "paragraph", content: [{ text: "Between" }] },
+ { type: "bulletListItem", content: [{ text: "Second" }] },
+ ]);
+ expect(blocks).toHaveLength(3);
+ });
+
+ it("preserves Google Docs blank paragraphs but leaves inline breaks alone", () => {
+ const blocks = parseOfficeHTML(
+ 'First
line
\n
\n
\nLast
',
+ );
+ expect(blocks).toMatchObject([
+ { type: "paragraph", content: [{ text: "First\nline", styles: {} }] },
+ { type: "paragraph", content: [] },
+ { type: "paragraph", content: [] },
+ { type: "paragraph", content: [{ text: "Last", styles: {} }] },
+ ]);
+ expect(blocks).toHaveLength(4);
+ });
+
+ it("leaves programmatic HTML imports unchanged", () => {
+ const blocks = createEditor().tryParseHTMLToBlocks(wordItem("Unchanged"));
+ expect(blocks).toMatchObject([
+ { type: "paragraph", content: [{ text: "·\u00a0Unchanged" }] },
+ ]);
+ });
+
+ it.each([
+ ["First
Last", "First\nLast"],
+ ["First
Last", "First\n\n\nLast"],
+ ["First
Last", "First\nLast"],
+ ["First \n
\n Last", "First \nLast"],
+ ])(
+ "keeps hard breaks in Google Docs inline fragments: %s",
+ (fragment, text) => {
+ const blocks = parseOfficeHTML(
+ `${fragment}`,
+ );
+ expect(blocks).toHaveLength(1);
+ expect(blocks[0]).toMatchObject({
+ type: "paragraph",
+ content: [{ text }],
+ });
+ },
+ );
+
+ it("leaves unrelated HTML unchanged", () => {
+ const html = "";
+ expect(normalizeOfficeHTML(html)).toBe(html);
+ });
+});
+
+function normalizedDOM(html: string) {
+ const root = document.createElement("div");
+ root.innerHTML = normalizeOfficeHTML(html);
+ return root;
+}
+
+function paragraph(text: string, level = 1, list = "l0", lfo = 1) {
+ return `· ${text}
`;
+}
+
+function nativeItem(text: string, level = 2, list = "l0", lfo = 1) {
+ return `${text}`;
+}
+
+function expectedItem(
+ text: string,
+ children: object[] = [],
+ type = "bulletListItem",
+) {
+ return { type, content: [{ text }], children };
+}
+
+describe("Word clipboard list transitions", () => {
+ // Reduced from the captured list/mixed-elements/input.word.html sample above:
+ // a level-one paragraph followed by whose li elements explicitly
+ // identify the same list and level two.
+ it("attaches wrapped native subitems to the preceding paragraph item", () => {
+ const blocks = parseOfficeHTML(
+ paragraph("One") +
+ paragraph("Three") +
+ `\n${nativeItem("Three sub one")}${nativeItem("Three sub two")}
\n
` +
+ paragraph("Four"),
+ );
+ expect(blocks).toHaveLength(3);
+ expect(blocks).toMatchObject([
+ expectedItem("One"),
+ expectedItem("Three", [
+ expectedItem("Three sub one"),
+ expectedItem("Three sub two"),
+ ]),
+ expectedItem("Four"),
+ ]);
+ });
+
+ it("continues native siblings and lets later paragraphs nest under the last one", () => {
+ const blocks = parseOfficeHTML(
+ paragraph("First") +
+ `${nativeItem("Second", 1)}${nativeItem("Third", 1)}
` +
+ paragraph("Third child", 2) +
+ paragraph("Last"),
+ );
+ expect(blocks).toHaveLength(4);
+ expect(blocks).toMatchObject([
+ expectedItem("First"),
+ expectedItem("Second"),
+ expectedItem("Third", [expectedItem("Third child")]),
+ expectedItem("Last"),
+ ]);
+ });
+
+ it("uses an existing native item as the parent of a following paragraph item", () => {
+ const blocks = parseOfficeHTML(
+ `${nativeItem("Parent", 1)}
` +
+ paragraph("Child", 2) +
+ paragraph("Sibling"),
+ );
+ expect(blocks).toHaveLength(2);
+ expect(blocks).toMatchObject([
+ expectedItem("Parent", [expectedItem("Child")], "numberedListItem"),
+ expectedItem("Sibling"),
+ ]);
+ });
+
+ it("preserves native ordered child lists and inline formatting", () => {
+ const blocks = parseOfficeHTML(
+ paragraph("Parent") +
+ `` +
+ paragraph("Grandchild", 3) +
+ paragraph("Next child", 2),
+ );
+ expect(blocks).toHaveLength(1);
+ expect(blocks).toMatchObject([
+ expectedItem("Parent", [
+ {
+ ...expectedItem(
+ "Child",
+ [expectedItem("Grandchild")],
+ "numberedListItem",
+ ),
+ content: [{ text: "Child", styles: { bold: true } }],
+ },
+ expectedItem("Next child"),
+ ]),
+ ]);
+ });
+
+ it.each([
+ ["different list", `${nativeItem("Child", 2, "l1")}
`],
+ [
+ "different instance",
+ `${nativeItem("Child", 2, "l0", 2)}
`,
+ ],
+ ["skipped level", `${nativeItem("Child", 3)}
`],
+ ["missing metadata", ""],
+ [
+ "inconsistent metadata",
+ `${nativeItem("Child")}${nativeItem("Other", 2, "l1")}
`,
+ ],
+ ["wrapper text", ``],
+ ])("does not guess a native parent with %s", (_name, native) => {
+ const root = normalizedDOM(paragraph("Parent") + native);
+ expect(root.querySelectorAll(":scope > ul")).toHaveLength(2);
+ expect(root.firstElementChild?.querySelector("li > ul")).toBeNull();
+ // The uncertain native fragment is preserved, rather than flattened here.
+ const original = document.createElement("div");
+ original.innerHTML = native;
+ expect(root.lastElementChild?.outerHTML).toBe(original.innerHTML);
+ });
+
+ it.each(["Boundary
", "Boundary", "
"])(
+ "does not attach native subitems across a content boundary: %s",
+ (boundary) => {
+ const root = normalizedDOM(
+ paragraph("Parent") +
+ boundary +
+ `` +
+ paragraph("Last"),
+ );
+ expect(root.querySelectorAll(":scope > ul")).toHaveLength(3);
+ expect(root.firstElementChild?.querySelector("li > ul")).toBeNull();
+ expect(root.lastElementChild?.textContent).toBe("Last");
+ },
+ );
+
+ it("keeps transitions local to a table cell", () => {
+ const root = normalizedDOM(
+ `${paragraph("Cell parent")}${nativeItem("Cell child")}
| ${nativeItem("Other cell")} ${paragraph("Other parent")} |
` +
+ paragraph("Outside"),
+ );
+ const cells = root.querySelectorAll("td");
+ expect(cells[0].querySelectorAll(":scope > ul")).toHaveLength(1);
+ expect(cells[0].querySelector("li > ul > li")?.textContent).toBe(
+ "Cell child",
+ );
+ expect(cells[1].querySelectorAll(":scope > ul")).toHaveLength(2);
+ expect(root.querySelector(":scope > ul")?.textContent).toBe("Outside");
+ expect(root.textContent).toBe(
+ "Cell parentCell childOther cellOther parentOutside",
+ );
+ });
+
+ it("does not create a second list around a native item's styled paragraph", () => {
+ const html = `${nativeItem('Content
', 1)}
`;
+ const root = normalizedDOM(html);
+ expect(root.querySelectorAll("li")).toHaveLength(1);
+ expect(root.querySelector("li > ul")).toBeNull();
+ expect(root.textContent).toBe("Content");
+ expect(parseOfficeHTML(html)).toMatchObject([expectedItem("Content")]);
+ });
+});
+
+describe("Google Docs clipboard normalization", () => {
+ // Source shapes are reduced from the repository's googleDocs parse fixture
+ // (tests/src/unit/core/formatConversion/parse/parseTestInstances.ts), plus
+ // CKEditor 5's captured Firefox paragraph wrapper:
+ // https://github.com/ckeditor/ckeditor5/blob/e7c72a94f7cc6be9dff5ae0bfb81af3eaa42f62d/packages/ckeditor5-paste-from-office/tests/_data/paste-from-google-docs/bold-wrapper/simple-text/input.firefox.html
+ // Copyright (c) 2003-2026, CKSource Holding sp. z o.o.
+ // Upstream license (GPL-2.0-or-later or commercial):
+ // https://github.com/ckeditor/ckeditor5/blob/e7c72a94f7cc6be9dff5ae0bfb81af3eaa42f62d/packages/ckeditor5/LICENSE.md
+ // Break-only selections below are synthetic boundary cases using those shapes.
+ it.each(["p", "h1", "span"])(
+ "preserves hard breaks in a source-marked %s without adding paragraphs",
+ (tag) => {
+ const blocks = parseOfficeHTML(
+ `<${tag} id="docs-internal-guid-test">
${tag}>`,
+ );
+ expect(blocks).toMatchObject([
+ {
+ type: tag === "h1" ? "heading" : "paragraph",
+ ...(tag === "h1" ? { props: { level: 1 } } : {}),
+ content: [{ text: "\n\n", styles: {} }],
+ children: [],
+ },
+ ]);
+ expect(blocks).toHaveLength(1);
+ },
+ );
+
+ it("keeps a Firefox paragraph wrapper's inline styles and hard breaks", () => {
+ const blocks = parseOfficeHTML(
+ 'Plain
Bold italic
',
+ );
+ expect(blocks).toMatchObject([
+ {
+ type: "paragraph",
+ content: [
+ { text: "Plain\n", styles: {} },
+ { text: "Bold italic", styles: { bold: true, italic: true } },
+ ],
+ children: [],
+ },
+ ]);
+ expect(blocks).toHaveLength(1);
+ });
+
+ it("keeps hard breaks when a source wrapper is inside a paragraph", () => {
+ const blocks = parseOfficeHTML(
+ '
',
+ );
+ expect(blocks).toMatchObject([
+ {
+ type: "paragraph",
+ content: [{ text: "\n\n", styles: {} }],
+ children: [],
+ },
+ ]);
+ expect(blocks).toHaveLength(1);
+ });
+
+ it("preserves blank paragraphs around lists and tables without expanding their hard breaks", () => {
+ const blocks = parseOfficeHTML(
+ '
',
+ );
+ expect(blocks).toMatchObject([
+ { type: "paragraph", content: [] },
+ {
+ type: "bulletListItem",
+ content: [{ text: "Bullet\nline", styles: {} }],
+ children: [
+ {
+ type: "numberedListItem",
+ content: [{ text: "Number", styles: {} }],
+ children: [],
+ },
+ ],
+ },
+ { type: "paragraph", content: [] },
+ {
+ type: "table",
+ content: {
+ rows: [
+ { cells: [{ content: [{ text: "Cell\nline", styles: {} }] }] },
+ ],
+ },
+ },
+ { type: "paragraph", content: [] },
+ ]);
+ expect(blocks).toHaveLength(5);
+ });
+});
diff --git a/packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts b/packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts
index 91c1f60fe6..3671559cec 100644
--- a/packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts
+++ b/packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts
@@ -14,6 +14,7 @@ import {
import { acceptedMIMETypes } from "./acceptedMIMETypes.js";
import { handleFileInsertion } from "./handleFileInsertion.js";
import { handleVSCodePaste } from "./handleVSCodePaste.js";
+import { normalizeOfficeHTML } from "./normalizeOfficeHTML.js";
function defaultPasteHandler({
event,
@@ -95,7 +96,7 @@ function defaultPasteHandler({
}
if (format === "text/html") {
- editor.pasteHTML(data);
+ editor.pasteHTML(normalizeOfficeHTML(data));
return true;
}