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
33 changes: 21 additions & 12 deletions packages/super-editor/src/core/super-converter/exporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,15 +170,15 @@ export function exportSchemaToJson(params) {
bookmarkEnd: wBookmarkEndTranslator,
fieldAnnotation: wSdtNodeTranslator,
tab: wTabNodeTranslator,
image: wDrawingNodeTranslator,
image: [wDrawingNodeTranslator, pictTranslator],
hardBreak: wBrNodeTranslator,
commentRangeStart: wCommentRangeStartTranslator,
commentRangeEnd: wCommentRangeEndTranslator,
permStart: wPermStartTranslator,
permEnd: wPermEndTranslator,
permStartBlock: wPermStartTranslator,
permEndBlock: wPermEndTranslator,
commentReference: () => null,
commentReference: [],
footnoteReference: wFootnoteReferenceTranslator,
shapeContainer: pictTranslator,
shapeTextbox: pictTranslator,
Expand All @@ -199,22 +199,31 @@ export function exportSchemaToJson(params) {
passthroughInline: translatePassthroughNode,
};

let handler = router[type];
const entry = router[type];

// For import/export v3 we use the translator directly
if (handler && 'decode' in handler && typeof handler.decode === 'function') {
return handler.decode(params);
}

if (!handler) {
if (!entry) {
console.error('No translation function found for node type:', type);
return null;
}
// Call the handler for this node type
return handler(params);

const handlers = Array.isArray(entry) ? entry : [entry];
for (const handler of handlers) {
let result;
if (handler && 'decode' in handler && typeof handler.decode === 'function') {
result = handler.decode(params);
} else if (typeof handler === 'function') {
result = handler(params);
}

if (result) {
return result;
}
}

return null;
}

function translatePassthroughNode(params) {
export function translatePassthroughNode(params) {
const original = params?.node?.attrs?.originalXml;
if (!original) return null;
return carbonCopy(original);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,77 +1,47 @@
// @ts-check
import { translator as pictTranslator } from '../../v3/handlers/w/pict/pict-translator';
import { translator as w_pPrTranslator } from '../../v3/handlers/w/pPr';
import { parseProperties } from './importerHelpers.js';

/** @type {Set<string>} */
const INLINE_PICT_RESULT_TYPES = new Set(['image', 'contentBlock']);
import { pictNodeTypeStrategy } from '@converter/v3/handlers/w/pict/helpers/pict-node-type-strategy';

/**
* Build paragraph attributes from a w:p node for wrapping inline pict results.
* @param {Object} pNode - The XML w:p node
* @param {Object} params - Import params containing docx context
* @returns {Object} Paragraph attributes including paragraphProperties, rsidRDefault, filename
* v2 handler that matches `w:pict` elements and delegates to the pict
* node-type strategy for import.
*
* NOTE: We intentionally avoid importing pict-translator here to prevent a
* circular initialisation chain:
* pictNodeImporter → pict-translator → translate-content-block → exporter
* → SuperConverter → docxImporter → pictNodeImporter
*
* @type {import("./types/index.js").NodeHandler}
*/
const buildParagraphAttrsFromPNode = (pNode, params) => {
const { attributes = {} } = parseProperties(pNode);
const pPr = pNode?.elements?.find((el) => el.name === 'w:pPr');
const inlineParagraphProperties = pPr ? w_pPrTranslator.encode({ ...params, nodes: [pPr] }) || {} : {};

return {
...attributes,
paragraphProperties: inlineParagraphProperties,
rsidRDefault: pNode?.attributes?.['w:rsidRDefault'],
filename: params?.filename,
};
};

export const handlePictNode = (params) => {
const { nodes } = params;

if (!nodes.length || nodes[0].name !== 'w:p') {
if (!Array.isArray(nodes) || nodes.length === 0 || nodes[0]?.name !== 'w:pict') {
return { nodes: [], consumed: 0 };
}

const pNode = nodes[0];
const runs = pNode.elements?.filter((el) => el.name === 'w:r') || [];

let pict = null;
for (const run of runs) {
const foundPict = run.elements?.find((el) => el.name === 'w:pict');
if (foundPict) {
pict = foundPict;
break;
}
}

// if there is no pict, then process as a paragraph or list.
if (!pict) {
const pict = nodes[0];
const { type: pictType, handler } = pictNodeTypeStrategy(pict);
if (!handler || pictType === 'unknown') {
return { nodes: [], consumed: 0 };
}

const node = pict;
const result = pictTranslator.encode({ ...params, extraParams: { node, pNode } });

if (!result) {
const result = handler({ params, pict });
if (!result) return { nodes: [], consumed: 0 };
const resultNodes = Array.isArray(result) ? result : [result];
// Block nodes (e.g. shapeContainer from v:textbox) cannot be returned from
// run-level parsing — the v2 handler list runs inside w:r children where only
// inline nodes are valid. Skip them here so the paragraph-level importer
// handles the whole w:p instead.
const BLOCK_TYPES = new Set(['shapeContainer', 'shapeTextbox']);
if (resultNodes.some((n) => BLOCK_TYPES.has(n.type))) {
return { nodes: [], consumed: 0 };
}

const shouldWrapInParagraph = INLINE_PICT_RESULT_TYPES.has(result.type);
const wrappedNode = shouldWrapInParagraph
? {
type: 'paragraph',
content: [result],
attrs: buildParagraphAttrsFromPNode(pNode, params),
marks: [],
}
: result;

return {
nodes: [wrappedNode],
nodes: resultNodes,
consumed: 1,
};
};

/**
* @type {import("./types/index.js").NodeHandlerEntry}
*/
export const pictNodeHandlerEntity = {
handlerName: 'handlePictNode',
handler: handlePictNode,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,123 +1,87 @@
import { describe, it, expect, vi } from 'vitest';
import { handlePictNode } from './pictNodeImporter.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';

vi.mock('../../v3/handlers/w/pict/pict-translator', () => ({
translator: {
encode: vi.fn(),
},
}));
const mockHandler = vi.fn();

vi.mock('../../v3/handlers/w/pPr', () => ({
translator: {
encode: vi.fn(() => ({ jc: 'center' })),
},
vi.mock('../../v3/handlers/w/pict/helpers/pict-node-type-strategy', () => ({
pictNodeTypeStrategy: vi.fn(),
}));

vi.mock('./importerHelpers.js', () => ({
parseProperties: vi.fn(() => ({ attributes: { testAttr: 'value' } })),
}));

import { translator as pictTranslator } from '../../v3/handlers/w/pict/pict-translator';

const createPNodeWithPict = (pictResult, pPrElements = [], rsidRDefault = '00AB1234') => ({
name: 'w:p',
attributes: { 'w:rsidRDefault': rsidRDefault },
elements: [
...(pPrElements.length ? [{ name: 'w:pPr', elements: pPrElements }] : []),
{
name: 'w:r',
elements: [{ name: 'w:pict', elements: [] }],
},
],
});
import { handlePictNode } from './pictNodeImporter.js';
import { pictNodeTypeStrategy } from '../../v3/handlers/w/pict/helpers/pict-node-type-strategy';

describe('handlePictNode', () => {
beforeEach(() => {
vi.clearAllMocks();
pictNodeTypeStrategy.mockReturnValue({ type: 'unknown', handler: null });
});

it('returns empty result when nodes array is empty', () => {
it('returns consumed: 0 when nodes array is empty', () => {
const result = handlePictNode({ nodes: [] });
expect(result).toEqual({ nodes: [], consumed: 0 });
});

it('returns empty result when first node is not w:p', () => {
const result = handlePictNode({ nodes: [{ name: 'w:r' }] });
it('returns consumed: 0 when params.nodes is missing', () => {
const result = handlePictNode({});
expect(result).toEqual({ nodes: [], consumed: 0 });
});

it('returns empty result when paragraph has no w:pict element', () => {
const pNode = {
name: 'w:p',
elements: [{ name: 'w:r', elements: [{ name: 'w:t' }] }],
};
const result = handlePictNode({ nodes: [pNode] });
it('returns consumed: 0 when first node is not w:pict', () => {
const result = handlePictNode({ nodes: [{ name: 'w:p' }] });
expect(result).toEqual({ nodes: [], consumed: 0 });
});

it('returns empty result when pictTranslator returns null', () => {
pictTranslator.encode.mockReturnValue(null);
const pNode = createPNodeWithPict(null);
const result = handlePictNode({ nodes: [pNode] });
it('returns consumed: 0 when strategy returns unknown type', () => {
pictNodeTypeStrategy.mockReturnValue({ type: 'unknown', handler: null });
const result = handlePictNode({ nodes: [{ name: 'w:pict', elements: [] }] });
expect(result).toEqual({ nodes: [], consumed: 0 });
});

it('wraps image result in a paragraph node', () => {
const imageResult = { type: 'image', attrs: { src: 'test.png' } };
pictTranslator.encode.mockReturnValue(imageResult);

const pNode = createPNodeWithPict(imageResult);
const result = handlePictNode({ nodes: [pNode], filename: 'document.xml' });

expect(result.consumed).toBe(1);
expect(result.nodes).toHaveLength(1);
expect(result.nodes[0].type).toBe('paragraph');
expect(result.nodes[0].content).toEqual([imageResult]);
expect(result.nodes[0].marks).toEqual([]);
expect(result.nodes[0].attrs.rsidRDefault).toBe('00AB1234');
expect(result.nodes[0].attrs.filename).toBe('document.xml');
});

it('wraps contentBlock result in a paragraph node', () => {
const contentBlockResult = { type: 'contentBlock', attrs: { id: '123' } };
pictTranslator.encode.mockReturnValue(contentBlockResult);

const pNode = createPNodeWithPict(contentBlockResult);
const result = handlePictNode({ nodes: [pNode] });

expect(result.nodes[0].type).toBe('paragraph');
expect(result.nodes[0].content).toEqual([contentBlockResult]);
it('returns consumed: 0 when handler returns null', () => {
mockHandler.mockReturnValue(null);
pictNodeTypeStrategy.mockReturnValue({ type: 'image', handler: mockHandler });
const result = handlePictNode({ nodes: [{ name: 'w:pict', elements: [] }] });
expect(result).toEqual({ nodes: [], consumed: 0 });
});

it('does not wrap non-inline results (e.g., passthroughBlock)', () => {
const passthroughResult = { type: 'passthroughBlock', attrs: { originalName: 'w:pict' } };
pictTranslator.encode.mockReturnValue(passthroughResult);
it('calls the strategy handler and returns the result wrapped in nodes array', () => {
const imageResult = { type: 'image', attrs: { src: 'test.png' } };
mockHandler.mockReturnValue(imageResult);
pictNodeTypeStrategy.mockReturnValue({ type: 'image', handler: mockHandler });

const pNode = createPNodeWithPict(passthroughResult);
const result = handlePictNode({ nodes: [pNode] });
const pictNode = { name: 'w:pict', elements: [] };
const params = { nodes: [pictNode], filename: 'document.xml' };
const result = handlePictNode(params);

expect(result.consumed).toBe(1);
expect(result.nodes).toHaveLength(1);
expect(result.nodes[0]).toEqual(passthroughResult);
expect(mockHandler).toHaveBeenCalledWith({ params, pict: pictNode });
expect(result).toEqual({ nodes: [imageResult], consumed: 1 });
});

it('includes paragraph properties from w:pPr in wrapped paragraph attrs', () => {
const imageResult = { type: 'image', attrs: { src: 'test.png' } };
pictTranslator.encode.mockReturnValue(imageResult);
it('passes through an array result from the handler without re-wrapping', () => {
const multiResult = [
{ type: 'image', attrs: { src: 'a.png' } },
{ type: 'image', attrs: { src: 'b.png' } },
];
mockHandler.mockReturnValue(multiResult);
pictNodeTypeStrategy.mockReturnValue({ type: 'image', handler: mockHandler });

const pNode = createPNodeWithPict(imageResult, [{ name: 'w:jc', attributes: { 'w:val': 'center' } }]);
const result = handlePictNode({ nodes: [pNode] });

expect(result.nodes[0].attrs.paragraphProperties).toEqual({ jc: 'center' });
const result = handlePictNode({ nodes: [{ name: 'w:pict', elements: [] }] });
expect(result).toEqual({ nodes: multiResult, consumed: 1 });
});

it('includes testAttr from parseProperties in wrapped paragraph attrs', () => {
const imageResult = { type: 'image', attrs: { src: 'test.png' } };
pictTranslator.encode.mockReturnValue(imageResult);
it('does not return block shapeContainer nodes from run-level pict parsing', () => {
const shapeContainerResult = {
type: 'shapeContainer',
attrs: { attributes: { id: '_x0000_s1026' } },
content: [{ type: 'paragraph', content: [] }],
};
mockHandler.mockReturnValue(shapeContainerResult);
pictNodeTypeStrategy.mockReturnValue({ type: 'shapeContainer', handler: mockHandler });

const pNode = createPNodeWithPict(imageResult);
const result = handlePictNode({ nodes: [pNode] });
const result = handlePictNode({
nodes: [{ name: 'w:pict', elements: [] }],
path: ['w:document', 'w:body', 'w:p', 'w:r'],
});

expect(result.nodes[0].attrs.testAttr).toBe('value');
expect(result).toEqual({ nodes: [], consumed: 0 });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ function decode(params) {
return null;
}

if (node.attrs.isPict) {
return null;
}

const childTranslator = node.attrs.isAnchor ? wpAnchorTranslator : wpInlineTranslator;
const resultNode = childTranslator.decode(params);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { carbonCopy } from '@core/utilities/carbonCopy.js';

/**
* Handles VML shape elements with v:imagedata (image watermarks).
*
Expand Down Expand Up @@ -75,10 +77,14 @@ export function handleShapeImageWatermarkImport({ params, pict }) {
const blacklevel = imagedataAttrs['blacklevel'];
const title = imagedataAttrs['o:title'] || 'Watermark';

// Pass through any extra children of the pict element
const passthroughElements = pict.elements.filter((el) => el !== shape);

// Build the image node
const imageNode = {
type: 'image',
attrs: {
isPict: true,
src: normalizedPath,
alt: title,
extension: normalizedPath.substring(normalizedPath.lastIndexOf('.') + 1),
Expand Down Expand Up @@ -119,6 +125,12 @@ export function handleShapeImageWatermarkImport({ params, pict }) {
},
};

// Store passthrough siblings as an attribute (not content) because image is
// a leaf node — PM would silently drop any content children.
if (passthroughElements.length > 0) {
imageNode.attrs.passthroughSiblings = passthroughElements.map((node) => carbonCopy(node));
}

return imageNode;
}

Expand Down
Loading
Loading