Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions packages/super-editor/src/core/commands/insertContent.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('<hr>', { 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 <hr> (no contentType) creates a horizontal rule', async () => {
const editor = await setupEditor();
expect(countHorizontalRules(editor)).toBe(0);

editor.commands.insertContent('<hr>');

expect(countHorizontalRules(editor)).toBe(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,128 @@ describe('translateVRectContentBlock', () => {
expect(generateRandomSigned32BitIntStrId).toHaveBeenCalled();
});
});

// ---------------------------------------------------------------------------
// VML fallback synthesis: horizontalRule nodes without legacy VML metadata
// (created via insertHorizontalRule or parsed from <hr> 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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<hr>` 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
Expand Down Expand Up @@ -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(),
},
];
},
Expand All @@ -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(),
});
},

Expand Down
Loading
Loading