diff --git a/packages/super-editor/src/assets/styles/elements/prosemirror.css b/packages/super-editor/src/assets/styles/elements/prosemirror.css index 2974f17ee6..d24b9bf76b 100644 --- a/packages/super-editor/src/assets/styles/elements/prosemirror.css +++ b/packages/super-editor/src/assets/styles/elements/prosemirror.css @@ -340,6 +340,11 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html z-index: -1; } +.ProseMirror div[data-horizontal-rule='true'] { + margin-top: auto; + align-self: flex-end; +} + .sd-editor-dropcap { float: left; display: flex; diff --git a/packages/super-editor/src/core/super-converter/exporter.js b/packages/super-editor/src/core/super-converter/exporter.js index a550cffc92..dbd43e9dd2 100644 --- a/packages/super-editor/src/core/super-converter/exporter.js +++ b/packages/super-editor/src/core/super-converter/exporter.js @@ -2118,8 +2118,14 @@ function translateShapeTextbox(params) { function translateContentBlock(params) { const { node } = params; - const { drawingContent } = node.attrs; + const { drawingContent, vmlAttributes, horizontalRule } = node.attrs; + // Handle VML v:rect elements (like horizontal rules) + if (vmlAttributes || horizontalRule) { + return translateVRectContentBlock(params); + } + + // Handle modern DrawingML content (existing logic) const drawing = { name: 'w:drawing', elements: [...(drawingContent ? [...(drawingContent.elements || [])] : [])], @@ -2136,12 +2142,56 @@ function translateContentBlock(params) { elements: [choice], }; - const par = { - name: 'w:p', - elements: [wrapTextInRun(alternateContent)], + return wrapTextInRun(alternateContent); +} + +function translateVRectContentBlock(params) { + const { node } = params; + const { vmlAttributes, background, attributes, style } = node.attrs; + + const rectAttrs = { + id: attributes?.id || `_x0000_i${Math.floor(Math.random() * 10000)}`, }; - return par; + if (style) { + rectAttrs.style = style; + } + + if (background) { + rectAttrs.fillcolor = background; + } + + if (vmlAttributes) { + if (vmlAttributes.hralign) rectAttrs['o:hralign'] = vmlAttributes.hralign; + if (vmlAttributes.hrstd) rectAttrs['o:hrstd'] = vmlAttributes.hrstd; + if (vmlAttributes.hr) rectAttrs['o:hr'] = vmlAttributes.hr; + if (vmlAttributes.stroked) rectAttrs.stroked = vmlAttributes.stroked; + } + + if (attributes) { + Object.entries(attributes).forEach(([key, value]) => { + if (!rectAttrs[key] && value !== undefined) { + rectAttrs[key] = value; + } + }); + } + + // Create the v:rect element + const rect = { + name: 'v:rect', + attributes: rectAttrs, + }; + + // Wrap in w:pict + const pict = { + name: 'w:pict', + attributes: { + 'w14:anchorId': Math.floor(Math.random() * 0xffffffff).toString(), + }, + elements: [rect], + }; + + return wrapTextInRun(pict); } export class DocxExporter { diff --git a/packages/super-editor/src/core/super-converter/v2/importer/pictNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/pictNodeImporter.js index 50ce0399d4..183951b6a5 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/pictNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/pictNodeImporter.js @@ -1,5 +1,6 @@ import { handleParagraphNode } from './paragraphNodeImporter.js'; import { defaultNodeListHandler } from './docxImporter.js'; +import { twipsToPixels, twipsToLines } from '../../helpers.js'; export const handlePictNode = (params) => { const { nodes } = params; @@ -20,6 +21,18 @@ export const handlePictNode = (params) => { const node = pict; const shape = node.elements?.find((el) => el.name === 'v:shape'); const group = node.elements?.find((el) => el.name === 'v:group'); + const rect = node.elements?.find((el) => el.name === 'v:rect'); + + // Handle v:rect elements (like horizontal rules) + if (rect) { + const result = handleVRectImport({ + pict, + pNode, + rect, + params, + }); + return { nodes: result ? [result] : [], consumed: 1 }; + } // such a case probably shouldn't exist. if (!shape && !group) { @@ -50,6 +63,89 @@ export const handlePictNode = (params) => { return { nodes: result ? [result] : [], consumed: 1 }; }; +// Handler for v:rect elements +export function handleVRectImport({ rect, pNode }) { + const schemaAttrs = {}; + const rectAttrs = rect.attributes || {}; + + // Store all the attributes you specified + schemaAttrs.attributes = rectAttrs; + + // Parse style attribute + if (rectAttrs.style) { + const parsedStyle = parseInlineStyles(rectAttrs.style); + const rectStyle = buildVRectStyles(parsedStyle); + + if (rectStyle) { + schemaAttrs.style = rectStyle; + } + + // Extract dimensions for the size attribute + const size = {}; + if (parsedStyle.width !== undefined) { + size.width = parsePointsToPixels(parsedStyle.width); + + // Check for full page width identifier and adjust width to be 100% + if (rectAttrs['o:hr'] === 't') { + size.width = '100%'; + } + } + if (parsedStyle.height !== undefined) { + size.height = parsePointsToPixels(parsedStyle.height); + } + if (Object.keys(size).length > 0) { + schemaAttrs.size = size; + } + } + + // Handle fillcolor + if (rectAttrs.fillcolor) { + schemaAttrs.background = rectAttrs.fillcolor; + } + + // Store VML-specific attributes + const vmlAttrs = {}; + if (rectAttrs['o:hralign']) vmlAttrs.hralign = rectAttrs['o:hralign']; + if (rectAttrs['o:hrstd']) vmlAttrs.hrstd = rectAttrs['o:hrstd']; + if (rectAttrs['o:hr']) vmlAttrs.hr = rectAttrs['o:hr']; + if (rectAttrs.stroked) vmlAttrs.stroked = rectAttrs.stroked; + + if (Object.keys(vmlAttrs).length > 0) { + schemaAttrs.vmlAttributes = vmlAttrs; + } + + // Determine if this is a horizontal rule + const isHorizontalRule = rectAttrs['o:hr'] === 't' || rectAttrs['o:hrstd'] === 't'; + if (isHorizontalRule) { + schemaAttrs.horizontalRule = true; + } + + const pPr = pNode.elements?.find((el) => el.name === 'w:pPr'); + const spacingElement = pPr?.elements?.find((el) => el.name === 'w:spacing'); + const spacingAttrs = spacingElement?.attributes || {}; + + // Parse spacing using the same logic as paragraphNodeImporter + const spacing = {}; + if (spacingAttrs['w:after']) spacing.lineSpaceAfter = twipsToPixels(spacingAttrs['w:after']); + if (spacingAttrs['w:before']) spacing.lineSpaceBefore = twipsToPixels(spacingAttrs['w:before']); + if (spacingAttrs['w:line']) spacing.line = twipsToLines(spacingAttrs['w:line']); + if (spacingAttrs['w:lineRule']) spacing.lineRule = spacingAttrs['w:lineRule']; + + return { + type: 'paragraph', + content: [ + { + type: 'contentBlock', + attrs: schemaAttrs, + }, + ], + attrs: { + spacing: Object.keys(spacing).length > 0 ? spacing : undefined, + rsidRDefault: pNode.attributes?.['w:rsidRDefault'], + }, + }; +} + export function handleShapTextboxImport({ shape, params }) { const schemaAttrs = {}; const schemaTextboxAttrs = {}; @@ -146,6 +242,42 @@ function buildStyles(styleObject) { return style; } +function buildVRectStyles(styleObject) { + let style = ''; + for (const [prop, value] of Object.entries(styleObject)) { + style += `${prop}: ${value};`; + } + + return style; +} + +export function parsePointsToPixels(value) { + if (typeof value !== 'string') return value; + + // Convert points to pixels (1pt ≈ 1.33px) + if (value.endsWith('pt')) { + const val = value.replace('pt', ''); + if (isNaN(val)) { + return 0; + } + const points = parseFloat(val); + return Math.round(points * 1.33); + } + + // Handle pixel values + if (value.endsWith('px')) { + const val = value.replace('px', ''); + if (isNaN(val)) { + return 0; + } + return parseInt(val); + } + + // Handle numeric values (assume pixels) + const numValue = parseFloat(value); + return isNaN(numValue) ? 0 : numValue; +} + export const pictNodeHandlerEntity = { handlerName: 'handlePictNode', handler: handlePictNode, 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 6b2d2760f3..22f54044d6 100644 --- a/packages/super-editor/src/extensions/content-block/content-block.js +++ b/packages/super-editor/src/extensions/content-block/content-block.js @@ -19,6 +19,13 @@ export const ContentBlock = Node.create({ addAttributes() { return { + horizontalRule: { + default: false, + renderDOM: ({ horizontalRule }) => { + if (!horizontalRule) return {}; + return { 'data-horizontal-rule': 'true' }; + }, + }, size: { default: null, renderDOM: ({ size }) => { @@ -27,8 +34,9 @@ export const ContentBlock = Node.create({ let style = ''; if (size.top) style += `top: ${size.top}px; `; if (size.left) style += `left: ${size.left}px; `; - if (size.width) style += `width: ${size.width}px; `; - if (size.height) style += `height: ${size.height}px; `; + if (size.width) style += `width: ${size.width.toString().endsWith('%') ? size.width : `${size.width}px`}; `; + if (size.height) + style += `height: ${size.height.toString().endsWith('%') ? size.height : `${size.height}px`}; `; return { style }; }, }, diff --git a/packages/super-editor/src/tests/data/vrect-node.docx b/packages/super-editor/src/tests/data/vrect-node.docx new file mode 100644 index 0000000000..7f8e552db9 Binary files /dev/null and b/packages/super-editor/src/tests/data/vrect-node.docx differ diff --git a/packages/super-editor/src/tests/export/rectNodeExporter.test.js b/packages/super-editor/src/tests/export/rectNodeExporter.test.js new file mode 100644 index 0000000000..0efb44ab94 --- /dev/null +++ b/packages/super-editor/src/tests/export/rectNodeExporter.test.js @@ -0,0 +1,24 @@ +import { expect, it, describe, beforeEach } from 'vitest'; +import { getExportedResult } from './export-helpers/index'; + +describe('RectNodeExporter', async () => { + const fileName = 'vrect-node.docx'; + const result = await getExportedResult(fileName); + const body = {}; + + beforeEach(() => { + Object.assign( + body, + result.elements?.find((el) => el.name === 'w:body'), + ); + }); + + it('should export v:rect with all attributes', () => { + const rect = body.elements[3].elements[1].elements[0].elements[0]; + expect(rect.attributes.id).toBe('_x0000_i1079'); + // Reverts back to the word doc value of 0 width + expect(rect.attributes.style).toBe('width: 0;height: 1.5pt;'); + expect(rect.attributes.fillcolor).toBe('#a0a0a0'); + expect(rect.attributes['o:hr']).toBe('t'); + }); +}); diff --git a/packages/super-editor/src/tests/import/rectImporter.test.js b/packages/super-editor/src/tests/import/rectImporter.test.js new file mode 100644 index 0000000000..3adb324cc5 --- /dev/null +++ b/packages/super-editor/src/tests/import/rectImporter.test.js @@ -0,0 +1,360 @@ +import { expect, it, describe, beforeEach } from 'vitest'; +import { + handlePictNode, + handleVRectImport, + buildVRectStyles, + parsePointsToPixels, +} from '../../core/super-converter/v2/importer/pictNodeImporter.js'; +import { defaultNodeListHandler } from '@converter/v2/importer/docxImporter.js'; + +describe('RectImporter', () => { + let mockParams; + + beforeEach(() => { + mockParams = { + docx: {}, + nodeListHandler: defaultNodeListHandler(), + }; + }); + + describe('handlePictNode with v:rect elements', () => { + it('should handle v:rect elements and return contentBlock', () => { + const nodes = [ + { + name: 'w:p', + elements: [ + { + name: 'w:r', + elements: [ + { + name: 'w:pict', + elements: [ + { + name: 'v:rect', + attributes: { + style: 'width: 100pt; height: 2pt;', + fillcolor: '#000000', + 'o:hr': 't', + 'o:hralign': 'center', + 'o:hrstd': 't', + stroked: 'f', + }, + }, + ], + }, + ], + }, + ], + }, + ]; + + const result = handlePictNode({ nodes, ...mockParams }); + + expect(result.nodes).toHaveLength(1); + expect(result.consumed).toBe(1); + + const node = result.nodes[0]; + expect(node.type).toBe('paragraph'); + expect(node.content).toHaveLength(1); + expect(node.content[0].type).toBe('contentBlock'); + + const contentBlock = node.content[0]; + expect(contentBlock.attrs.attributes).toBeDefined(); + expect(contentBlock.attrs.style).toBeDefined(); + expect(contentBlock.attrs.size).toBeDefined(); + expect(contentBlock.attrs.background).toBe('#000000'); + expect(contentBlock.attrs.horizontalRule).toBe(true); + expect(contentBlock.attrs.vmlAttributes).toBeDefined(); + }); + + it('should handle v:rect elements without style attribute', () => { + const nodes = [ + { + name: 'w:p', + elements: [ + { + name: 'w:r', + elements: [ + { + name: 'w:pict', + elements: [ + { + name: 'v:rect', + attributes: { + fillcolor: '#FF0000', + 'o:hr': 't', + }, + }, + ], + }, + ], + }, + ], + }, + ]; + + const result = handlePictNode({ nodes, ...mockParams }); + + expect(result.nodes).toHaveLength(1); + expect(result.consumed).toBe(1); + + const node = result.nodes[0]; + const contentBlock = node.content[0]; + expect(contentBlock.attrs.attributes).toBeDefined(); + expect(contentBlock.attrs.style).toBeUndefined(); + expect(contentBlock.attrs.size).toBeUndefined(); + expect(contentBlock.attrs.background).toBe('#FF0000'); + expect(contentBlock.attrs.horizontalRule).toBe(true); + }); + + it('should handle v:rect elements with paragraph spacing', () => { + const nodes = [ + { + name: 'w:p', + attributes: { 'w:rsidRDefault': 'test123' }, + elements: [ + { + name: 'w:pPr', + elements: [ + { + name: 'w:spacing', + attributes: { + 'w:after': '240', // 12pt in twips + 'w:before': '120', // 6pt in twips + 'w:line': '276', // 1.15 line spacing + 'w:lineRule': 'auto', + }, + }, + ], + }, + { + name: 'w:r', + elements: [ + { + name: 'w:pict', + elements: [ + { + name: 'v:rect', + attributes: { + style: 'width: 50pt; height: 1pt;', + 'o:hr': 't', + }, + }, + ], + }, + ], + }, + ], + }, + ]; + + const result = handlePictNode({ nodes, ...mockParams }); + + expect(result.nodes).toHaveLength(1); + expect(result.consumed).toBe(1); + + const node = result.nodes[0]; + expect(node.attrs.rsidRDefault).toBe('test123'); + expect(node.attrs.spacing).toBeDefined(); + expect(node.attrs.spacing.lineSpaceAfter).toBe(16); // 240 twips = 12px + expect(node.attrs.spacing.lineSpaceBefore).toBe(8); // 120 twips = 6px + expect(node.attrs.spacing.line).toBe(1.15); + expect(node.attrs.spacing.lineRule).toBe('auto'); + }); + + it('should return empty result for non-v:rect pict elements', () => { + const nodes = [ + { + name: 'w:p', + elements: [ + { + name: 'w:r', + elements: [ + { + name: 'w:pict', + elements: [ + { + name: 'v:shape', + attributes: { style: 'width: 100pt; height: 50pt;' }, + }, + ], + }, + ], + }, + ], + }, + ]; + + const result = handlePictNode({ nodes, ...mockParams }); + + expect(result.nodes).toHaveLength(0); + expect(result.consumed).toBe(1); + }); + + it('should return empty result for non-pict nodes', () => { + const nodes = [ + { + name: 'w:p', + elements: [ + { + name: 'w:r', + elements: [ + { + name: 'w:drawing', + attributes: {}, + }, + ], + }, + ], + }, + ]; + + const result = handlePictNode({ nodes, ...mockParams }); + + expect(result.nodes).toHaveLength(0); + expect(result.consumed).toBe(0); + }); + }); + + describe('handleVRectImport', () => { + it('should process v:rect with full style attributes', () => { + const rect = { + attributes: { + style: 'width: 200pt; height: 3pt; margin-left: 10pt;', + fillcolor: '#333333', + 'o:hr': 't', + 'o:hralign': 'left', + 'o:hrstd': 't', + stroked: 't', + }, + }; + + const pNode = { + elements: [ + { + name: 'w:pPr', + elements: [ + { + name: 'w:spacing', + attributes: { 'w:after': '240' }, + }, + ], + }, + ], + }; + + const result = handleVRectImport({ rect, pNode }); + + expect(result.type).toBe('paragraph'); + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe('contentBlock'); + + const contentBlock = result.content[0]; + expect(contentBlock.attrs.attributes).toEqual(rect.attributes); + expect(contentBlock.attrs.style).toBe('width: 200pt;height: 3pt;margin-left: 10pt;'); + expect(contentBlock.attrs.size.height).toBe(4); // 3pt * 1.33 ≈ 4px + expect(contentBlock.attrs.background).toBe('#333333'); + expect(contentBlock.attrs.horizontalRule).toBe(true); + expect(contentBlock.attrs.vmlAttributes.hralign).toBe('left'); + expect(contentBlock.attrs.vmlAttributes.hrstd).toBe('t'); + expect(contentBlock.attrs.vmlAttributes.hr).toBe('t'); + expect(contentBlock.attrs.vmlAttributes.stroked).toBe('t'); + + // If hr is true, the width should be 100% + expect(contentBlock.attrs.size.width).toBe('100%'); + }); + + it('should handle v:rect with o:hr attribute for full page width', () => { + const rect = { + attributes: { + style: 'width: 100pt; height: 2pt;', + 'o:hr': 't', + }, + }; + + const pNode = { elements: [] }; + + const result = handleVRectImport({ rect, pNode }); + + const contentBlock = result.content[0]; + expect(contentBlock.attrs.size.width).toBe('100%'); + expect(contentBlock.attrs.horizontalRule).toBe(true); + }); + + it('should handle v:rect without style attribute', () => { + const rect = { + attributes: { + fillcolor: '#CCCCCC', + 'o:hrstd': 't', + }, + }; + + const pNode = { elements: [] }; + + const result = handleVRectImport({ rect, pNode }); + + const contentBlock = result.content[0]; + expect(contentBlock.attrs.style).toBeUndefined(); + expect(contentBlock.attrs.size).toBeUndefined(); + expect(contentBlock.attrs.background).toBe('#CCCCCC'); + expect(contentBlock.attrs.horizontalRule).toBe(true); + }); + + it('should handle v:rect with only basic attributes', () => { + const rect = { + attributes: { + id: 'rect1', + fillcolor: '#FF0000', + }, + }; + + const pNode = { elements: [] }; + + const result = handleVRectImport({ rect, pNode }); + + const contentBlock = result.content[0]; + expect(contentBlock.attrs.attributes.id).toBe('rect1'); + expect(contentBlock.attrs.background).toBe('#FF0000'); + expect(contentBlock.attrs.horizontalRule).toBeFalsy(); + expect(contentBlock.attrs.vmlAttributes).toBeUndefined(); + }); + }); + + describe('parsePointsToPixels', () => { + it('should convert points to pixels correctly', () => { + expect(parsePointsToPixels('12pt')).toBe(16); // 12 * 1.33 ≈ 16 + expect(parsePointsToPixels('24pt')).toBe(32); // 24 * 1.33 ≈ 32 + expect(parsePointsToPixels('6pt')).toBe(8); // 6 * 1.33 ≈ 8 + }); + + it('should handle pixel values', () => { + expect(parsePointsToPixels('16px')).toBe(16); + expect(parsePointsToPixels('32px')).toBe(32); + expect(parsePointsToPixels('100px')).toBe(100); + }); + + it('should handle numeric values', () => { + expect(parsePointsToPixels('100')).toBe(100); + expect(parsePointsToPixels('50')).toBe(50); + expect(parsePointsToPixels('0')).toBe(0); + }); + + it('should handle non-string values', () => { + expect(parsePointsToPixels(100)).toBe(100); + expect(parsePointsToPixels(50)).toBe(50); + expect(parsePointsToPixels(null)).toBe(null); + expect(parsePointsToPixels(undefined)).toBe(undefined); + }); + + it('should handle invalid string values', () => { + expect(parsePointsToPixels('invalid')).toBe(0); + expect(parsePointsToPixels('abcpt')).toBe(0); + expect(parsePointsToPixels('')).toBe(0); + }); + + it('should round pixel values correctly', () => { + expect(parsePointsToPixels('10pt')).toBe(13); // 10 * 1.33 = 13.3, rounded to 13 + expect(parsePointsToPixels('15pt')).toBe(20); // 15 * 1.33 = 19.95, rounded to 20 + }); + }); +});