Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { parseTagValueJSON } from './parse-tag-value-json';
import { parseMarks } from '@converter/v2/importer/markImporter';
import { generateDocxRandomId } from '@core/helpers/generateDocxRandomId';
import { getSdtEnvelopeParts } from './sdt-envelope';

/**
* @param {Object} params
Expand All @@ -14,8 +15,7 @@ export function handleAnnotationNode(params) {
}

const node = nodes[0];
const sdtPr = node.elements.find((el) => el.name === 'w:sdtPr');
const sdtContent = node.elements.find((el) => el.name === 'w:sdtContent');
const { sdtPr, sdtContent } = getSdtEnvelopeParts(node);

const sdtId = sdtPr?.elements?.find((el) => el.name === 'w:id');
const alias = sdtPr?.elements.find((el) => el.name === 'w:alias');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getSdtEnvelopeParts } from './sdt-envelope';

/**
* @param {Object} params
* @returns {Array|null}
Expand All @@ -10,23 +12,21 @@ export function handleDocPartObj(params) {
}

const node = nodes[0];
const sdtPr = node.elements.find((el) => el.name === 'w:sdtPr');
const { sdtPr, sdtContent } = getSdtEnvelopeParts(node);
const docPartObj = sdtPr?.elements.find((el) => el.name === 'w:docPartObj');
const docPartGallery = docPartObj?.elements.find((el) => el.name === 'w:docPartGallery');
const docPartGalleryType = docPartGallery?.attributes?.['w:val'] ?? null;

const content = node?.elements.find((el) => el.name === 'w:sdtContent');

// SD-1333: emit inline only when the SDT both sits inside a w:p AND its
// sdtContent has no direct w:p/w:tbl children. Word emits Table-of-Figures
// SDTs inside a w:p with real w:p children inside sdtContent — those must
// stay block so the paragraph translator can hoist them.
const isInsideParagraph = (params.path || []).some((p) => p?.name === 'w:p');
const hasBlockChild = !!content?.elements?.some((el) => el?.name === 'w:p' || el?.name === 'w:tbl');
const hasBlockChild = !!sdtContent?.elements?.some((el) => el?.name === 'w:p' || el?.name === 'w:tbl');
if (isInsideParagraph && !hasBlockChild) {
return inlineDocPartHandler({
...params,
nodes: [content],
nodes: [sdtContent],
extraParams: { ...(params.extraParams || {}), sdtPr, docPartGalleryType },
});
}
Expand All @@ -35,7 +35,7 @@ export function handleDocPartObj(params) {
const handler = validGalleryTypeMap[docPartGalleryType] || genericDocPartHandler;
const result = handler({
...params,
nodes: [content],
nodes: [sdtContent],
extraParams: { ...(params.extraParams || {}), sdtPr, docPartGalleryType },
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseTagValueJSON } from './parse-tag-value-json';
import { getSdtEnvelopeParts } from './sdt-envelope';

/**
* Handle document section node. Special case of w:sdt nodes
Expand All @@ -13,7 +14,7 @@ export function handleDocumentSectionNode(params) {
}

const node = nodes[0];
const sdtPr = node.elements.find((el) => el.name === 'w:sdtPr');
const { sdtPr, sdtContent } = getSdtEnvelopeParts(node);
const tag = sdtPr?.elements.find((el) => el.name === 'w:tag');
const tagValue = parseTagValueJSON(tag?.attributes?.['w:val']);

Expand All @@ -30,7 +31,6 @@ export function handleDocumentSectionNode(params) {
const lockValue = lockTag?.attributes?.['w:val'];
const isLocked = lockValue === 'sdtContentLocked';

const sdtContent = node.elements.find((el) => el.name === 'w:sdtContent');
const translatedContent = nodeListHandler.handler({
...params,
nodes: sdtContent?.elements,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { parseAnnotationMarks } from './handle-annotation-node';
import { parseStrictStOnOff } from '../../../utils.js';
import { getSdtEnvelopeParts } from './sdt-envelope';

/**
* Detect the semantic control type from sdtPr child elements.
Expand Down Expand Up @@ -87,8 +88,7 @@ export function handleStructuredContentNode(params) {
}

const node = nodes[0];
const sdtPr = node.elements.find((el) => el.name === 'w:sdtPr');
const sdtContent = node.elements.find((el) => el.name === 'w:sdtContent');
const { sdtPr, sdtContent } = getSdtEnvelopeParts(node);

const id = sdtPr?.elements?.find((el) => el.name === 'w:id');
const tag = sdtPr?.elements?.find((el) => el.name === 'w:tag');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// @ts-check

/**
* Extract the common OOXML structured document tag envelope pieces.
*
* @param {any} node
* @returns {{ sdtPr: any, sdtEndPr: any, sdtContent: any }}
*/
export const getSdtEnvelopeParts = (node) => {
const elements = Array.isArray(node?.elements) ? node.elements : [];
return {
sdtPr: elements.find((el) => el?.name === 'w:sdtPr') ?? null,
sdtEndPr: elements.find((el) => el?.name === 'w:sdtEndPr') ?? null,
sdtContent: elements.find((el) => el?.name === 'w:sdtContent') ?? null,
};
};

/**
* Normalize direct children plus same-level SDT wrappers into the child stream
* a parent translator consumes.
*
* @param {any} parent
* @param {{ childName: string, metadataKey: string, scope: string }} config
* @returns {Array<{ node: any } & Record<string, any>>}
*/
export const normalizeSdtContentChildren = (parent, { childName, metadataKey, scope }) => {
const out = [];
const children = Array.isArray(parent?.elements) ? parent.elements : [];
for (const child of children) {
if (!child || typeof child.name !== 'string') continue;
if (child.name === childName) {
out.push({ node: child, [metadataKey]: null });
continue;
}
if (child.name === 'w:sdt') {
const { sdtPr, sdtEndPr, sdtContent } = getSdtEnvelopeParts(child);
const sdtContentElements = Array.isArray(sdtContent?.elements) ? sdtContent.elements : [];
const innerChildren = sdtContentElements.filter((el) => el?.name === childName);
if (innerChildren.length === 1 && sdtPr) {
const childIndex = sdtContentElements.indexOf(innerChildren[0]);
const contentBefore = sdtContentElements.slice(0, childIndex);
const contentAfter = sdtContentElements.slice(childIndex + 1);
out.push({
node: innerChildren[0],
[metadataKey]: {
scope,
sdtPr,
sdtEndPr,
...(contentBefore.length > 0 && { contentBefore }),
...(contentAfter.length > 0 && { contentAfter }),
},
});
} else {
for (const innerChild of innerChildren) {
out.push({ node: innerChild, [metadataKey]: null });
}
}
}
}
return out;
};

/**
* Re-wrap exported child elements that carry preserved SDT envelope metadata.
*
* @param {any[]} elements
* @param {any[]} sourceChildren
* @param {{ childName: string, metadataKey: string, scope: string }} config
* @returns {any[]}
*/
export const wrapSdtContentChildren = (elements, sourceChildren, { childName, metadataKey, scope }) => {
let sourceCursor = 0;
for (let i = 0; i < elements.length; i += 1) {
const exportedEl = elements[i];
if (!exportedEl || exportedEl.name !== childName) continue;
const sourceChild = sourceChildren?.[sourceCursor];
sourceCursor += 1;
const sdtMetadata = sourceChild?.attrs?.[metadataKey];
if (!sdtMetadata || sdtMetadata.scope !== scope || !sdtMetadata.sdtPr) continue;
const sdtChildren = [sdtMetadata.sdtPr];
if (sdtMetadata.sdtEndPr) sdtChildren.push(sdtMetadata.sdtEndPr);
const contentBefore = Array.isArray(sdtMetadata.contentBefore) ? sdtMetadata.contentBefore : [];
const contentAfter = Array.isArray(sdtMetadata.contentAfter) ? sdtMetadata.contentAfter : [];
sdtChildren.push({ name: 'w:sdtContent', elements: [...contentBefore, exportedEl, ...contentAfter] });
elements[i] = { name: 'w:sdt', elements: sdtChildren };
}
return elements;
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { handleAnnotationNode } from './handle-annotation-node';
import { handleDocPartObj } from './handle-doc-part-obj';
import { handleDocumentSectionNode } from './handle-document-section-node';
import { handleStructuredContentNode } from './handle-structured-content-node';
import { getSdtEnvelopeParts } from './sdt-envelope';

/**
* There are multiple types of w:sdt nodes.
Expand All @@ -13,8 +14,7 @@ import { handleStructuredContentNode } from './handle-structured-content-node';
* @returns {Object}
*/
export function sdtNodeTypeStrategy(node) {
const sdtContent = node.elements.find((el) => el.name === 'w:sdtContent');
const sdtPr = node.elements.find((el) => el.name === 'w:sdtPr');
const { sdtPr, sdtContent } = getSdtEnvelopeParts(node);
const tag = sdtPr?.elements.find((el) => el.name === 'w:tag');
const tagValue = tag?.attributes?.['w:val'];
const docPartObj = sdtPr?.elements.find((el) => el.name === 'w:docPartObj');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// @ts-check
import { normalizeSdtContentChildren } from '../sdt/helpers/sdt-envelope.js';

/**
* Normalize a `<w:tbl>` element's children into the row stream the table encoder
* iterates. Direct `<w:tr>` children pass through unchanged. A row-level
* `<w:sdt>` (ECMA-376 §17.5.2.30, CT_SdtRow) is unwrapped: its inner `<w:tr>`
* is emitted in document order, and when the wrapper contains exactly one row
* the wrapper's `w:sdtPr` / `w:sdtEndPr` are attached as metadata so export can
* rebuild the `<w:sdt>` envelope.
*
* Multi-row SDT wrappers are imported defensively: every inner row is emitted in
* order, but wrapper metadata is dropped because exact multi-row grouping needs
* a representation SuperDoc does not currently model.
*
* @param {any} table
* @returns {Array<{ node: any, rowSdt: any }>}
*/
export const normalizeTableRowChildren = (table) => {
return /** @type {Array<{ node: any, rowSdt: any }>} */ (
normalizeSdtContentChildren(table, { childName: 'w:tr', metadataKey: 'rowSdt', scope: 'row' })
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// @ts-check
import { describe, it, expect } from 'vitest';
import { normalizeTableRowChildren } from './table-row-children.js';

const SDT_PR = { name: 'w:sdtPr', elements: [{ name: 'w:id', attributes: { 'w:val': '849213029' } }] };
const SDT_END_PR = { name: 'w:sdtEndPr', elements: [] };
const FIRST_ROW = { name: 'w:tr', elements: [{ name: 'w:tc', elements: [] }] };
const SECOND_ROW = { name: 'w:tr', elements: [{ name: 'w:tc', elements: [] }] };
const BOOKMARK_START = { name: 'w:bookmarkStart', attributes: { 'w:id': '1', 'w:name': 'row-start' } };
const BOOKMARK_END = { name: 'w:bookmarkEnd', attributes: { 'w:id': '1' } };

describe('normalizeTableRowChildren', () => {
it('emits direct table rows unchanged', () => {
const table = { name: 'w:tbl', elements: [FIRST_ROW] };

expect(normalizeTableRowChildren(table)).toEqual([{ node: FIRST_ROW, rowSdt: null }]);
});

it('unwraps a single-row row-level SDT and preserves wrapper metadata', () => {
const table = {
name: 'w:tbl',
elements: [
{
name: 'w:sdt',
elements: [SDT_PR, SDT_END_PR, { name: 'w:sdtContent', elements: [FIRST_ROW] }],
},
],
};

expect(normalizeTableRowChildren(table)).toEqual([
{
node: FIRST_ROW,
rowSdt: { scope: 'row', sdtPr: SDT_PR, sdtEndPr: SDT_END_PR },
},
]);
});

it('preserves non-row SDT content siblings around a single imported row', () => {
const table = {
name: 'w:tbl',
elements: [
{
name: 'w:sdt',
elements: [SDT_PR, { name: 'w:sdtContent', elements: [BOOKMARK_START, FIRST_ROW, BOOKMARK_END] }],
},
],
};

expect(normalizeTableRowChildren(table)).toEqual([
{
node: FIRST_ROW,
rowSdt: {
scope: 'row',
sdtPr: SDT_PR,
sdtEndPr: null,
contentBefore: [BOOKMARK_START],
contentAfter: [BOOKMARK_END],
},
},
]);
});

it('imports multi-row wrappers without applying wrapper metadata to individual rows', () => {
const table = {
name: 'w:tbl',
elements: [
{
name: 'w:sdt',
elements: [SDT_PR, { name: 'w:sdtContent', elements: [FIRST_ROW, SECOND_ROW] }],
},
],
};

expect(normalizeTableRowChildren(table)).toEqual([
{ node: FIRST_ROW, rowSdt: null },
{ node: SECOND_ROW, rowSdt: null },
]);
});

it('skips row-level SDTs without row content', () => {
const table = { name: 'w:tbl', elements: [{ name: 'w:sdt', elements: [SDT_PR] }] };

expect(normalizeTableRowChildren(table)).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import { NodeTranslator } from '@translator';
import { translator as tblGridTranslator } from '../tblGrid';
import { translator as tblPrTranslator } from '../tblPr';
import { translator as trTranslator } from '../tr';
import { normalizeRowCellChildren } from '../tr/row-cell-children.js';
import { wrapSdtContentChildren } from '../sdt/helpers/sdt-envelope.js';
import { normalizeTableRowChildren } from './table-row-children.js';

/**
* Legacy table identity attributes imported from older SuperDoc exports.
Expand Down Expand Up @@ -49,10 +52,10 @@ const sumColumnTwips = (columns = []) =>
* @returns {number | null} Total cell width in twips, or null if incomplete
*/
const getFirstRowCellWidthSumTwips = (rows = []) => {
const firstRow = rows.find((row) => row?.elements?.some((el) => el.name === 'w:tc'));
const firstRow = rows.find((row) => normalizeRowCellChildren(row).length > 0);
if (!firstRow?.elements) return null;

const cells = firstRow.elements.filter((el) => el.name === 'w:tc');
const cells = normalizeRowCellChildren(firstRow).map((entry) => entry.node);
if (!cells.length) return null;

let sum = 0;
Expand Down Expand Up @@ -162,7 +165,8 @@ const encode = (params, encodedAttrs) => {
};

// Process each row
const rows = node.elements.filter((el) => el.name === 'w:tr');
const rowEntries = normalizeTableRowChildren(node);
const rows = rowEntries.map((entry) => entry.node);
let columnWidths = Array.isArray(encodedAttrs['grid'])
? encodedAttrs['grid'].map((item) => twipsToPixels(item.col))
: [];
Expand Down Expand Up @@ -206,7 +210,7 @@ const encode = (params, encodedAttrs) => {
const totalColumns = columnWidths.length;
const totalRows = rows.length;
const activeRowSpans = totalColumns > 0 ? new Array(totalColumns).fill(0) : [];
rows.forEach((row, rowIndex) => {
rowEntries.forEach(({ node: row, rowSdt }, rowIndex) => {
const result = trTranslator.encode({
...params,
path: [...(params.path || []), node],
Expand All @@ -225,6 +229,9 @@ const encode = (params, encodedAttrs) => {
},
});
if (result) {
if (rowSdt) {
result.attrs = { ...(result.attrs || {}), rowSdt };
}
content.push(result);

if (totalColumns > 0) {
Expand Down Expand Up @@ -302,6 +309,12 @@ const decode = (params, decodedAttrs) => {

const elements = translateChildNodes({ ...params, extraParams });

// Re-wrap rows that were originally imported as row-level SDT
// (ECMA-376 §17.5.2.30, CT_SdtRow). The table schema contains only tableRow
// children, so each exported `<w:tr>` advances the source row cursor once;
// table properties/grid are inserted after this pass and cannot shift it.
wrapSdtContentChildren(elements, node.content || [], { childName: 'w:tr', metadataKey: 'rowSdt', scope: 'row' });

// Table grid - generate if not present
const firstRow = node.content?.find((n) => n.type === 'tableRow');
const element = tblGridTranslator.decode({
Expand Down
Loading
Loading