diff --git a/packages/super-editor/src/core/super-converter/exporter.js b/packages/super-editor/src/core/super-converter/exporter.js index 5e59ad6f4..4a49e5a88 100644 --- a/packages/super-editor/src/core/super-converter/exporter.js +++ b/packages/super-editor/src/core/super-converter/exporter.js @@ -260,7 +260,8 @@ function generateParagraphProperties(node) { const { styleId } = attrs; if (styleId) pPrElements.push({ name: 'w:pStyle', attributes: { 'w:val': styleId } }); - const { spacing, indent, textAlign, textIndent, lineHeight, marksAttrs, keepLines, keepNext, dropcap } = attrs; + const { spacing, indent, textAlign, textIndent, lineHeight, marksAttrs, keepLines, keepNext, dropcap, borders } = + attrs; if (spacing) { const { lineSpaceBefore, lineSpaceAfter, lineRule } = spacing; @@ -401,12 +402,41 @@ function generateParagraphProperties(node) { if (numPr && !hasNumPr) pPrElements.push(numPr); if (!pPrElements.length) return null; + if (borders && Object.keys(borders).length) { + pPrElements.push(generateParagraphBorders(borders)); + } + return { name: 'w:pPr', elements: pPrElements, }; } +function generateParagraphBorders(borders) { + const elements = []; + const sides = ['top', 'bottom', 'left', 'right']; + sides.forEach((side) => { + const b = borders[side]; + if (!b) return; + + let attributes; + if (!b.size) { + attributes = { 'w:val': 'nil' }; + } else { + attributes = { + 'w:val': b.val || 'single', + 'w:sz': pixelsToEightPoints(b.size), + 'w:space': b.space ? pixelsToEightPoints(b.space) : 0, + 'w:color': (b.color || '#000000').replace('#', ''), + }; + } + + elements.push({ name: `w:${side}`, attributes }); + }); + + return { name: 'w:pBdr', elements }; +} + /** * Translate a document node * diff --git a/packages/super-editor/src/core/super-converter/v2/importer/markImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/markImporter.js index b3f13e52c..e7a9011af 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/markImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/markImporter.js @@ -26,7 +26,6 @@ export function parseMarks(property, unknownMarks = [], docx = null) { 'w:numPr', 'w:outlineLvl', 'w:bdr', - 'w:pBdr', 'w:noProof', 'w:contextualSpacing', 'w:keepNext', diff --git a/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js index 813bb31a8..fde97ba21 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js @@ -1,4 +1,4 @@ -import { twipsToInches, twipsToLines, twipsToPixels, twipsToPt } from '../../helpers.js'; +import { twipsToInches, twipsToLines, twipsToPixels, twipsToPt, eigthPointsToPixels } from '../../helpers.js'; import { carbonCopy } from '../../../utilities/carbonCopy.js'; import { mergeTextNodes } from './mergeTextNodes.js'; import { parseMarks } from './markImporter.js'; @@ -42,6 +42,14 @@ export const handleParagraphNode = (params) => { } const pPr = node.elements?.find((el) => el.name === 'w:pPr'); + // Extract paragraph borders if present + const pBdr = pPr?.elements?.find((el) => el.name === 'w:pBdr'); + if (pBdr) { + const borders = parseParagraphBorders(pBdr); + if (Object.keys(borders).length) { + schemaNode.attrs.borders = borders; + } + } const styleTag = pPr?.elements?.find((el) => el.name === 'w:pStyle'); const nestedRPr = pPr?.elements?.find((el) => el.name === 'w:rPr'); const framePr = pPr?.elements?.find((el) => el.name === 'w:framePr'); @@ -181,6 +189,38 @@ export const handleParagraphNode = (params) => { return { nodes: schemaNode ? [schemaNode] : [], consumed: 1 }; }; +function parseParagraphBorders(pBdr) { + if (!pBdr || !pBdr.elements) return {}; + // These are the possible sides + const sides = ['top', 'bottom', 'left', 'right']; + const result = {}; + + sides.forEach((side) => { + const el = pBdr.elements.find((e) => e.name === `w:${side}`); + if (!el || !el.attributes) return; + + const { attributes: a } = el; + if (a['w:val'] === 'nil' || a['w:val'] === undefined) return; + + // Set size of border + let sizePx; + if (a['w:sz'] !== undefined) sizePx = eigthPointsToPixels(a['w:sz']); + + // Track space of border + let spacePx; + if (a['w:space'] !== undefined) spacePx = eigthPointsToPixels(a['w:space']); + + result[side] = { + val: a['w:val'], + size: sizePx, + space: spacePx, + color: a['w:color'] ? `#${a['w:color']}` : '#000000', + }; + }); + + return result; +} + export const getParagraphIndent = (node, docx, styleId = '') => { const indent = { left: 0, diff --git a/packages/super-editor/src/extensions/paragraph/paragraph.js b/packages/super-editor/src/extensions/paragraph/paragraph.js index d807ee9b1..dfea3581e 100644 --- a/packages/super-editor/src/extensions/paragraph/paragraph.js +++ b/packages/super-editor/src/extensions/paragraph/paragraph.js @@ -77,6 +77,39 @@ export const Paragraph = Node.create({ return { style }; }, }, + borders: { + default: null, + renderDOM: ({ borders }) => { + if (!borders) return {}; + + const sideOrder = ['top', 'right', 'bottom', 'left']; + const valToCss = { + single: 'solid', + dashed: 'dashed', + dotted: 'dotted', + double: 'double', + }; + + let style = ''; + sideOrder.forEach((side) => { + const b = borders[side]; + if (!b) return; + + const width = b.size != null ? `${b.size}px` : '1px'; + const cssStyle = valToCss[b.val] || 'solid'; + const color = b.color || '#000000'; + + style += `border-${side}: ${width} ${cssStyle} ${color};`; + + // Optionally handle space attribute (distance from text) + if (b.space != null && side === 'bottom') { + style += `padding-bottom: ${b.space}px;`; + } + }); + + return style ? { style } : {}; + }, + }, class: { renderDOM: (attributes) => { if (attributes.dropcap) { diff --git a/packages/super-editor/src/tests/import/paragraphNodeImporter.test.js b/packages/super-editor/src/tests/import/paragraphNodeImporter.test.js index 51758b9ab..57e71420a 100644 --- a/packages/super-editor/src/tests/import/paragraphNodeImporter.test.js +++ b/packages/super-editor/src/tests/import/paragraphNodeImporter.test.js @@ -265,6 +265,130 @@ describe('paragraph tests to check spacing', () => { // textIndent should be in inches (2880twips - 270twips(hanging)) expect(node.attrs.textIndent).toBe('1.81in'); }); + + it('correctly parses paragraph borders', () => { + const mockParagraph = { + name: 'w:p', + elements: [ + { + name: 'w:pPr', + elements: [ + { + name: 'w:pBdr', + elements: [ + { + name: 'w:bottom', + attributes: { + 'w:val': 'single', + 'w:sz': '8', + 'w:space': '0', + 'w:color': 'DDDDDD', + }, + }, + ], + }, + ], + }, + { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Border text' }] }], + }, + ], + }; + + const { nodes } = handleParagraphNode({ + nodes: [mockParagraph], + docx: {}, + nodeListHandler: defaultNodeListHandler(), + }); + + const node = nodes[0]; + expect(node.type).toBe('paragraph'); + expect(node.attrs.borders).toBeDefined(); + expect(node.attrs.borders.bottom).toEqual({ + val: 'single', + size: expect.any(Number), + space: expect.any(Number), + color: '#DDDDDD', + }); + }); + + it('ignores borders without w:val', () => { + const mockParagraph = { + name: 'w:p', + elements: [ + { + name: 'w:pPr', + elements: [ + { + name: 'w:pBdr', + elements: [ + { + name: 'w:top', + attributes: { + // no w:val attribute + 'w:sz': '4', + 'w:color': 'FF0000', + }, + }, + ], + }, + ], + }, + ], + }; + + const { nodes } = handleParagraphNode({ + nodes: [mockParagraph], + docx: {}, + nodeListHandler: defaultNodeListHandler(), + }); + + const p = nodes[0]; + expect(p.type).toBe('paragraph'); + expect(p.attrs.borders).toBeUndefined(); + }); + + it('captures all four border sides', () => { + const sides = ['top', 'bottom', 'left', 'right']; + const borderElements = sides.map((side) => ({ + name: `w:${side}`, + attributes: { + 'w:val': 'single', + 'w:sz': '8', + 'w:color': '0000FF', + }, + })); + + const mockParagraph = { + name: 'w:p', + elements: [ + { + name: 'w:pPr', + elements: [ + { + name: 'w:pBdr', + elements: borderElements, + }, + ], + }, + ], + }; + + const { nodes } = handleParagraphNode({ + nodes: [mockParagraph], + docx: {}, + nodeListHandler: defaultNodeListHandler(), + }); + + const p = nodes[0]; + expect(p.attrs.borders).toBeDefined(); + sides.forEach((side) => { + expect(p.attrs.borders[side]).toBeDefined(); + expect(p.attrs.borders[side].val).toBe('single'); + expect(p.attrs.borders[side].color).toBe('#0000FF'); + }); + }); }); describe('paragraph tests to check indentation', () => {