diff --git a/packages/super-editor/src/core/commands/insertContent.test.js b/packages/super-editor/src/core/commands/insertContent.test.js
index 22c57b7242..38ca141e97 100644
--- a/packages/super-editor/src/core/commands/insertContent.test.js
+++ b/packages/super-editor/src/core/commands/insertContent.test.js
@@ -327,3 +327,71 @@ describe('insertContent (integration) list export', () => {
expect(Number(topBorder?.attributes?.['w:sz'])).toBeGreaterThan(0);
});
});
+
+// ---------------------------------------------------------------------------
+// CI-only: horizontal rule insertion (requires real Editor which depends on
+// @superdoc/document-api — unresolvable in local workspace, works in CI).
+// ---------------------------------------------------------------------------
+describe.skipIf(!process.env.CI)('insertContent (integration) horizontal rule', () => {
+ let helpers = null;
+ let cachedDocxData = null;
+
+ const setupEditor = async () => {
+ vi.resetModules();
+ vi.doUnmock('../helpers/contentProcessor.js');
+
+ if (!helpers) {
+ helpers = await import('../../tests/helpers/helpers.js');
+ }
+ if (!cachedDocxData) {
+ cachedDocxData = await helpers.loadTestDataForEditorTests('blank-doc.docx');
+ }
+
+ const { docx, media, mediaFiles, fonts } = cachedDocxData;
+ const { editor } = helpers.initTestEditor({ content: docx, media, mediaFiles, fonts, mode: 'docx' });
+ return editor;
+ };
+
+ const countHorizontalRules = (editor) => {
+ let count = 0;
+ const content = editor.getJSON().content || [];
+ for (const block of content) {
+ if (block.type === 'contentBlock' && block.attrs?.horizontalRule) count++;
+ // contentBlock is inline — check inside paragraph > run or paragraph directly
+ for (const inline of block.content || []) {
+ if (inline.type === 'contentBlock' && inline.attrs?.horizontalRule) count++;
+ for (const child of inline.content || []) {
+ if (child.type === 'contentBlock' && child.attrs?.horizontalRule) count++;
+ }
+ }
+ }
+ return count;
+ };
+
+ it('insertContent with contentType html creates a horizontal rule', async () => {
+ const editor = await setupEditor();
+ expect(countHorizontalRules(editor)).toBe(0);
+
+ editor.commands.insertContent('
', { contentType: 'html' });
+
+ expect(countHorizontalRules(editor)).toBe(1);
+ });
+
+ it('insertContent with contentType markdown creates a horizontal rule', async () => {
+ const editor = await setupEditor();
+ expect(countHorizontalRules(editor)).toBe(0);
+
+ editor.commands.insertContent('---', { contentType: 'markdown' });
+
+ expect(countHorizontalRules(editor)).toBe(1);
+ });
+
+ it('insertContent with bare
(no contentType) creates a horizontal rule', async () => {
+ const editor = await setupEditor();
+ expect(countHorizontalRules(editor)).toBe(0);
+
+ editor.commands.insertContent('
');
+
+ expect(countHorizontalRules(editor)).toBe(1);
+ });
+});
diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.js
index fb329cefa5..9d0e1896d5 100644
--- a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.js
+++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.js
@@ -19,18 +19,106 @@ export function translateContentBlock(params) {
return wrapTextInRun(alternateContent);
}
+// Nominal full-width value for VML style. Word ignores this when o:hr="t"
+// is present and renders the rect at full page width instead.
+const FULL_WIDTH_PT = '468pt';
+const FULL_WIDTH_PT_VALUE = 468;
+
+// Conversion factor matching the importer (1pt ~= 1.33px).
+const PX_PER_PT = 1.33;
+
+/**
+ * Convert a pixel value to a VML point string (e.g. 2 -> "1.5pt").
+ * Rounds to one decimal place to match typical OOXML precision.
+ * @param {number} px
+ * @returns {string}
+ */
+function pxToPt(px) {
+ const pt = Math.round((px / PX_PER_PT) * 10) / 10;
+ return `${pt}pt`;
+}
+
+/**
+ * Convert supported size values to VML point strings.
+ * Supports:
+ * - numbers (treated as px)
+ * - pixel strings (e.g. "200px")
+ * - numeric strings (e.g. "200")
+ * - percentages for width (e.g. "50%", "100%")
+ * @param {unknown} value
+ * @param {{ allowPercent?: boolean }} [options]
+ * @returns {string|null}
+ */
+function sizeToPt(value, options = {}) {
+ const { allowPercent = false } = options;
+
+ if (typeof value === 'number') {
+ return Number.isFinite(value) ? pxToPt(value) : null;
+ }
+
+ if (typeof value !== 'string') {
+ return null;
+ }
+
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+
+ if (allowPercent && trimmed.endsWith('%')) {
+ const percent = Number.parseFloat(trimmed.slice(0, -1));
+ if (!Number.isFinite(percent) || percent <= 0) return null;
+
+ if (percent >= 100) return FULL_WIDTH_PT;
+
+ const pt = Math.round(((FULL_WIDTH_PT_VALUE * percent) / 100) * 10) / 10;
+ return `${pt}pt`;
+ }
+
+ const normalized = trimmed.endsWith('px') ? trimmed.slice(0, -2) : trimmed;
+ const px = Number.parseFloat(normalized);
+ if (!Number.isFinite(px)) return null;
+
+ return pxToPt(px);
+}
+
+/**
+ * Build a VML style string from the node's `size` attribute.
+ * Used as a fallback when no raw `style` was preserved from import.
+ * @param {{ width?: unknown, height?: unknown }} size
+ * @returns {string}
+ */
+function synthesizeVmlStyle(size) {
+ const parts = [];
+
+ if (size.width != null) {
+ const widthPt = sizeToPt(size.width, { allowPercent: true });
+ if (widthPt) {
+ parts.push(`width:${widthPt}`);
+ }
+ }
+
+ if (size.height != null) {
+ const heightPt = sizeToPt(size.height);
+ if (heightPt) {
+ parts.push(`height:${heightPt}`);
+ }
+ }
+
+ return parts.join(';');
+}
+
/**
* @param {Object} params - The parameters for translation.
* @returns {Object} The XML representation.
*/
export function translateVRectContentBlock(params) {
const { node } = params;
- const { vmlAttributes, background, attributes, style } = node.attrs;
+ const { horizontalRule, vmlAttributes, background, attributes, style, size } = node.attrs;
const rectAttrs = {
id: attributes?.id || `_x0000_i${Math.floor(Math.random() * 10000)}`,
};
+ // --- Style (VML CSS dimensions) ---
if (style) {
rectAttrs.style = style;
}
@@ -39,6 +127,7 @@ export function translateVRectContentBlock(params) {
rectAttrs.fillcolor = background;
}
+ // --- VML HR flags ---
if (vmlAttributes) {
if (vmlAttributes.hralign) rectAttrs['o:hralign'] = vmlAttributes.hralign;
if (vmlAttributes.hrstd) rectAttrs['o:hrstd'] = vmlAttributes.hrstd;
@@ -54,6 +143,22 @@ export function translateVRectContentBlock(params) {
});
}
+ // Synthesize style only when not already provided by style/attributes.
+ if (!rectAttrs.style && horizontalRule && size) {
+ const synthesized = synthesizeVmlStyle(size);
+ if (synthesized) {
+ rectAttrs.style = synthesized;
+ }
+ }
+
+ // Ensure horizontal-rule VML flags are complete even if metadata is partial.
+ if (horizontalRule) {
+ if (rectAttrs['o:hr'] == null) rectAttrs['o:hr'] = 't';
+ if (rectAttrs['o:hrstd'] == null) rectAttrs['o:hrstd'] = 't';
+ if (rectAttrs['o:hralign'] == null) rectAttrs['o:hralign'] = 'center';
+ if (rectAttrs.stroked == null) rectAttrs.stroked = 'f';
+ }
+
// Create the v:rect element
const rect = {
name: 'v:rect',
diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.test.js
index 7df8eb264b..1af103dbd6 100644
--- a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.test.js
+++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-content-block.test.js
@@ -185,3 +185,128 @@ describe('translateVRectContentBlock', () => {
expect(generateRandomSigned32BitIntStrId).toHaveBeenCalled();
});
});
+
+// ---------------------------------------------------------------------------
+// VML fallback synthesis: horizontalRule nodes without legacy VML metadata
+// (created via insertHorizontalRule or parsed from
tags).
+// ---------------------------------------------------------------------------
+describe('translateVRectContentBlock - VML synthesis for new HRs', () => {
+ /** Helper: build params matching createDefaultHorizontalRuleAttrs() output. */
+ const buildNewHRParams = (overrides = {}) => ({
+ node: {
+ attrs: {
+ horizontalRule: true,
+ size: { width: '100%', height: 2 },
+ background: '#e5e7eb',
+ ...overrides,
+ },
+ },
+ });
+
+ /** Helper: extract the v:rect attributes from the translated result. */
+ const getRectAttrs = (result) => result.elements[0].elements[0].attributes;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ generateRandomSigned32BitIntStrId.mockReturnValue('12345678');
+ wrapTextInRun.mockImplementation((content) => ({ name: 'w:r', elements: [content] }));
+ });
+
+ it('should synthesize VML HR flags when vmlAttributes is absent', () => {
+ const rectAttrs = getRectAttrs(translateVRectContentBlock(buildNewHRParams()));
+
+ expect(rectAttrs['o:hr']).toBe('t');
+ expect(rectAttrs['o:hrstd']).toBe('t');
+ expect(rectAttrs['o:hralign']).toBe('center');
+ expect(rectAttrs.stroked).toBe('f');
+ });
+
+ it('should synthesize VML style from size for full-width HR', () => {
+ const rectAttrs = getRectAttrs(translateVRectContentBlock(buildNewHRParams()));
+
+ // width: 100% -> nominal 468pt, height: 2px -> 1.5pt
+ expect(rectAttrs.style).toBe('width:468pt;height:1.5pt');
+ });
+
+ it('should synthesize VML style from size for fixed-width HR', () => {
+ const params = buildNewHRParams({ size: { width: 200, height: 3 } });
+ const rectAttrs = getRectAttrs(translateVRectContentBlock(params));
+
+ // 200px / 1.33 ~= 150.4pt, 3px / 1.33 ~= 2.3pt
+ expect(rectAttrs.style).toBe('width:150.4pt;height:2.3pt');
+ });
+
+ it('should synthesize VML style from percentage width without NaN values', () => {
+ const params = buildNewHRParams({ size: { width: '50%', height: 2 } });
+ const rectAttrs = getRectAttrs(translateVRectContentBlock(params));
+
+ expect(rectAttrs.style).toBe('width:234pt;height:1.5pt');
+ expect(rectAttrs.style).not.toContain('NaN');
+ });
+
+ it('should synthesize VML style from px strings without NaN values', () => {
+ const params = buildNewHRParams({ size: { width: '200px', height: '3px' } });
+ const rectAttrs = getRectAttrs(translateVRectContentBlock(params));
+
+ expect(rectAttrs.style).toBe('width:150.4pt;height:2.3pt');
+ expect(rectAttrs.style).not.toContain('NaN');
+ });
+
+ it('should omit invalid style dimensions instead of emitting NaNpt', () => {
+ const params = buildNewHRParams({ size: { width: 'auto', height: 2 } });
+ const rectAttrs = getRectAttrs(translateVRectContentBlock(params));
+
+ expect(rectAttrs.style).toBe('height:1.5pt');
+ expect(rectAttrs.style).not.toContain('NaN');
+ });
+
+ it('should set fillcolor from background', () => {
+ const rectAttrs = getRectAttrs(translateVRectContentBlock(buildNewHRParams()));
+
+ expect(rectAttrs.fillcolor).toBe('#e5e7eb');
+ });
+
+ it('should synthesize missing HR flags when vmlAttributes is partial', () => {
+ const params = buildNewHRParams({
+ vmlAttributes: { hralign: 'left' },
+ });
+ const rectAttrs = getRectAttrs(translateVRectContentBlock(params));
+
+ expect(rectAttrs['o:hralign']).toBe('left');
+ expect(rectAttrs['o:hr']).toBe('t');
+ expect(rectAttrs['o:hrstd']).toBe('t');
+ expect(rectAttrs.stroked).toBe('f');
+ });
+
+ it('should preserve explicit vmlAttributes over synthesis', () => {
+ const params = buildNewHRParams({
+ vmlAttributes: { hr: 't', hrstd: 't', hralign: 'left', stroked: 'f' },
+ });
+ const rectAttrs = getRectAttrs(translateVRectContentBlock(params));
+
+ // Uses the explicit value, not the synthesized default
+ expect(rectAttrs['o:hralign']).toBe('left');
+ });
+
+ it('should preserve explicit style over synthesis', () => {
+ const params = buildNewHRParams({ style: 'width:300pt;height:2pt' });
+ const rectAttrs = getRectAttrs(translateVRectContentBlock(params));
+
+ expect(rectAttrs.style).toBe('width:300pt;height:2pt');
+ });
+
+ it('should produce a complete exportable v:rect for a default HR', () => {
+ const rectAttrs = getRectAttrs(translateVRectContentBlock(buildNewHRParams()));
+
+ // Every attribute Word needs to render an HR should be present
+ expect(rectAttrs).toMatchObject({
+ style: 'width:468pt;height:1.5pt',
+ fillcolor: '#e5e7eb',
+ 'o:hr': 't',
+ 'o:hrstd': 't',
+ 'o:hralign': 'center',
+ stroked: 'f',
+ });
+ expect(rectAttrs.id).toBeDefined();
+ });
+});
diff --git a/packages/super-editor/src/extensions/content-block/content-block.js b/packages/super-editor/src/extensions/content-block/content-block.js
index 03020ea48f..7e94fbed14 100644
--- a/packages/super-editor/src/extensions/content-block/content-block.js
+++ b/packages/super-editor/src/extensions/content-block/content-block.js
@@ -43,6 +43,20 @@ import { Node, Attribute } from '@core/index.js';
* })
*/
+/**
+ * Default attributes for a horizontal rule content block.
+ * Single source of truth shared by both `parseDOM` (for `
` tags)
+ * and the `insertHorizontalRule` command.
+ * @returns {ContentBlockAttributes}
+ */
+export function createDefaultHorizontalRuleAttrs() {
+ return {
+ horizontalRule: true,
+ size: { width: '100%', height: 2 },
+ background: '#e5e7eb',
+ };
+}
+
/**
* @module ContentBlock
* @sidebarTitle Content Block
@@ -147,6 +161,14 @@ export const ContentBlock = Node.create({
return [
{
tag: `div[data-type="${this.name}"]`,
+ // Paragraph registers a broad `tag: 'div'` rule at default priority 50.
+ // Without explicit priority, PM's insertion-order tie-breaking lets
+ // paragraph consume our div first. Priority 60 ensures contentBlock wins.
+ priority: 60,
+ },
+ {
+ tag: 'hr',
+ getAttrs: () => createDefaultHorizontalRuleAttrs(),
},
];
},
@@ -170,11 +192,7 @@ export const ContentBlock = Node.create({
({ commands }) => {
return commands.insertContent({
type: this.name,
- attrs: {
- horizontalRule: true,
- size: { width: '100%', height: 2 },
- background: '#e5e7eb',
- },
+ attrs: createDefaultHorizontalRuleAttrs(),
});
},
diff --git a/packages/super-editor/src/extensions/content-block/content-block.test.js b/packages/super-editor/src/extensions/content-block/content-block.test.js
new file mode 100644
index 0000000000..2751e040ff
--- /dev/null
+++ b/packages/super-editor/src/extensions/content-block/content-block.test.js
@@ -0,0 +1,198 @@
+import { describe, it, expect } from 'vitest';
+import { Schema, DOMParser } from 'prosemirror-model';
+import { createDefaultHorizontalRuleAttrs } from './content-block.js';
+import { createDocFromHTML } from '../../core/helpers/importHtml.js';
+import { createDocFromMarkdown } from '../../core/helpers/importMarkdown.js';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Build a minimal ProseMirror schema that mirrors the real extension setup:
+ * - paragraph with a broad `div` rule at default priority (50)
+ * - contentBlock with `div[data-type]` at priority 60 and `hr` rule
+ *
+ * This reproduces the real parser priority interaction between paragraph
+ * and contentBlock without pulling in the full extension framework.
+ */
+function buildTestSchema() {
+ return new Schema({
+ nodes: {
+ doc: { content: 'block+' },
+
+ paragraph: {
+ group: 'block',
+ content: 'inline*',
+ parseDOM: [{ tag: 'p' }, { tag: 'div' }],
+ },
+
+ contentBlock: {
+ group: 'inline',
+ content: '',
+ atom: true,
+ inline: true,
+ isolating: true,
+ attrs: {
+ horizontalRule: { default: false },
+ size: { default: null },
+ background: { default: null },
+ },
+ parseDOM: [
+ { tag: 'div[data-type="contentBlock"]', priority: 60 },
+ { tag: 'hr', getAttrs: () => createDefaultHorizontalRuleAttrs() },
+ ],
+ toDOM(node) {
+ return ['div', { 'data-type': 'contentBlock' }];
+ },
+ },
+
+ text: { group: 'inline' },
+ },
+ });
+}
+
+/**
+ * Parse an HTML string using the test schema's DOMParser.
+ */
+function parseHTML(html) {
+ const schema = buildTestSchema();
+ const container = document.createElement('div');
+ container.innerHTML = html;
+ return DOMParser.fromSchema(schema).parse(container);
+}
+
+/**
+ * Find the first node of the given type in a PM document.
+ * Returns null if not found.
+ */
+function findNode(doc, typeName) {
+ let found = null;
+ doc.descendants((node) => {
+ if (!found && node.type.name === typeName) {
+ found = node;
+ return false;
+ }
+ });
+ return found;
+}
+
+/**
+ * Build a mock editor suitable for createDocFromHTML / createDocFromMarkdown.
+ * happy-dom provides the global `document` so the import pipeline has DOM access.
+ */
+function buildMockEditor() {
+ return { schema: buildTestSchema(), options: {} };
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('contentBlock
parsing', () => {
+ describe('raw DOMParser', () => {
+ it('parses
into a contentBlock with horizontalRule attrs', () => {
+ const doc = parseHTML('
');
+
+ const cb = findNode(doc, 'contentBlock');
+ expect(cb).not.toBeNull();
+ expect(cb.attrs.horizontalRule).toBe(true);
+ expect(cb.attrs.size).toEqual({ width: '100%', height: 2 });
+ expect(cb.attrs.background).toBe('#e5e7eb');
+ });
+
+ it('auto-wraps the inline contentBlock in a paragraph', () => {
+ const doc = parseHTML('
');
+
+ // doc > paragraph > contentBlock
+ const paragraph = doc.firstChild;
+ expect(paragraph.type.name).toBe('paragraph');
+
+ const cb = findNode(paragraph, 'contentBlock');
+ expect(cb).not.toBeNull();
+ });
+
+ it('parses
alongside other content', () => {
+ const doc = parseHTML('before
after
');
+
+ const blocks = [];
+ doc.forEach((child) => blocks.push(child));
+
+ expect(blocks).toHaveLength(3);
+ expect(blocks[0].type.name).toBe('paragraph');
+ expect(blocks[0].textContent).toBe('before');
+ expect(blocks[1].type.name).toBe('paragraph');
+ expect(findNode(blocks[1], 'contentBlock')).not.toBeNull();
+ expect(blocks[2].type.name).toBe('paragraph');
+ expect(blocks[2].textContent).toBe('after');
+ });
+ });
+
+ describe('full HTML import pipeline', () => {
+ it('parses
through createDocFromHTML', () => {
+ const editor = buildMockEditor();
+ const doc = createDocFromHTML('
', editor);
+
+ const cb = findNode(doc, 'contentBlock');
+ expect(cb).not.toBeNull();
+ expect(cb.attrs.horizontalRule).toBe(true);
+ expect(cb.attrs.size).toEqual({ width: '100%', height: 2 });
+ expect(cb.attrs.background).toBe('#e5e7eb');
+ });
+ });
+
+ describe('full markdown import pipeline', () => {
+ it('parses --- through createDocFromMarkdown', () => {
+ const editor = buildMockEditor();
+ const doc = createDocFromMarkdown('---', editor);
+
+ const cb = findNode(doc, 'contentBlock');
+ expect(cb).not.toBeNull();
+ expect(cb.attrs.horizontalRule).toBe(true);
+ expect(cb.attrs.size).toEqual({ width: '100%', height: 2 });
+ expect(cb.attrs.background).toBe('#e5e7eb');
+ });
+ });
+});
+
+describe('contentBlock shared defaults', () => {
+ it('insertHorizontalRule and
parsing use the same default attrs', () => {
+ // Parse an
and extract the attrs set by getAttrs
+ const doc = parseHTML('
');
+ const parsedAttrs = findNode(doc, 'contentBlock').attrs;
+
+ // Get the attrs the insertHorizontalRule command would use
+ const commandAttrs = createDefaultHorizontalRuleAttrs();
+
+ expect(parsedAttrs.horizontalRule).toBe(commandAttrs.horizontalRule);
+ expect(parsedAttrs.size).toEqual(commandAttrs.size);
+ expect(parsedAttrs.background).toBe(commandAttrs.background);
+ });
+});
+
+describe('contentBlock div[data-type] parsing', () => {
+ it('still parses div[data-type="contentBlock"] correctly', () => {
+ const doc = parseHTML('');
+
+ const cb = findNode(doc, 'contentBlock');
+ expect(cb).not.toBeNull();
+ expect(cb.attrs.horizontalRule).toBe(false);
+ });
+
+ it('priority prevents paragraph from consuming div[data-type="contentBlock"]', () => {
+ // If priority were missing, paragraph's broad `div` rule (priority 50)
+ // would match first because paragraph registers before contentBlock.
+ // With priority 60, contentBlock's rule wins.
+ const doc = parseHTML('');
+
+ const cb = findNode(doc, 'contentBlock');
+ expect(cb).not.toBeNull();
+
+ // contentBlock is inline, so PM wraps it in a paragraph.
+ // Verify the paragraph contains a contentBlock child (not that the
+ // div was consumed as a paragraph itself with no contentBlock inside).
+ const topChild = doc.firstChild;
+ expect(topChild.type.name).toBe('paragraph');
+ expect(findNode(topChild, 'contentBlock')).not.toBeNull();
+ });
+});