Skip to content

Commit e495fac

Browse files
committed
fix(document-api): preserve markdown table style metadata in structural writes
1 parent 6bf6cea commit e495fac

7 files changed

Lines changed: 384 additions & 27 deletions

File tree

packages/document-api/src/receipt-bridge.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ export function textReceiptToSDReceipt(receipt: TextMutationReceipt): SDMutation
3838
success: true,
3939
resolution: receipt.resolution
4040
? {
41-
requestedTarget: receipt.resolution.requestedTarget
42-
? textAddressToSDAddress(receipt.resolution.requestedTarget)
43-
: undefined,
41+
...(receipt.resolution.requestedTarget
42+
? { requestedTarget: textAddressToSDAddress(receipt.resolution.requestedTarget) }
43+
: {}),
4444
target: textAddressToSDAddress(receipt.resolution.target),
4545
}
4646
: undefined,
@@ -75,9 +75,9 @@ export function textReceiptToSDReceipt(receipt: TextMutationReceipt): SDMutation
7575
failure,
7676
resolution: receipt.resolution
7777
? {
78-
requestedTarget: receipt.resolution.requestedTarget
79-
? textAddressToSDAddress(receipt.resolution.requestedTarget)
80-
: undefined,
78+
...(receipt.resolution.requestedTarget
79+
? { requestedTarget: textAddressToSDAddress(receipt.resolution.requestedTarget) }
80+
: {}),
8181
target: textAddressToSDAddress(receipt.resolution.target),
8282
}
8383
: undefined,

packages/super-editor/src/document-api-adapters/helpers/sd-projection.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpe
33
import type { Editor } from '../../core/Editor.js';
44
import { projectContentNode, projectInlineNode, projectDocument } from './sd-projection.js';
55
import { executeStructuralInsert } from '../structural-write-engine/index.js';
6+
import { markdownToPmFragment } from '../../core/helpers/markdown/markdownToPmContent.js';
67
import type { SDFragment, SDParagraph, SDHeading, SDTable, SDRun, SDHyperlink } from '@superdoc/document-api';
78

89
let docData: Awaited<ReturnType<typeof loadTestDataForEditorTests>>;
@@ -138,6 +139,16 @@ describe('projectContentNode', () => {
138139
expect(projected.table.rows[0].cells[0].content.length).toBeGreaterThan(0);
139140
});
140141

142+
it('projects markdown table width and normalization marker', () => {
143+
const { fragment } = markdownToPmFragment('| Col A | Col B |\n| --- | --- |\n| foo | bar |', editor);
144+
expect(fragment.childCount).toBeGreaterThan(0);
145+
146+
const projected = projectContentNode(fragment.child(0)) as SDTable;
147+
expect(projected.kind).toBe('table');
148+
expect(projected.table.props?.width).toEqual({ kind: 'percent', value: 5000 });
149+
expect((projected.ext as any)?.superdoc?.needsTableStyleNormalization).toBe(true);
150+
});
151+
141152
it('preserves sdBlockId as id', () => {
142153
executeStructuralInsert(editor, {
143154
content: {

packages/super-editor/src/document-api-adapters/helpers/sd-projection.ts

Lines changed: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,7 @@ function buildHeading(
418418

419419
function projectTable(pmNode: ProseMirrorNode): SDTable {
420420
const attrs = pmNode.attrs as TableAttrs | undefined;
421+
const pmAttrs = pmNode.attrs as Record<string, unknown>;
421422
const rows: SDTableRow[] = [];
422423

423424
pmNode.forEach((child) => {
@@ -432,14 +433,31 @@ function projectTable(pmNode: ProseMirrorNode): SDTable {
432433
table: { rows },
433434
};
434435

435-
const styleRef = attrs?.tableProperties?.tableStyleId ?? (pmNode.attrs as any)?.tableStyleId;
436+
const styleRef = attrs?.tableProperties?.tableStyleId ?? (pmAttrs as any)?.tableStyleId;
436437
if (styleRef) result.table.styleRef = styleRef;
437438

438-
const gridModel = (pmNode.attrs as any)?.tableGridModel ?? attrs?.tableGrid?.colWidths;
439+
const props = extractTableProps(attrs, pmAttrs);
440+
if (props) result.table.props = props;
441+
442+
const gridModel = (pmAttrs as any)?.grid ?? (pmAttrs as any)?.tableGridModel ?? attrs?.tableGrid?.colWidths;
439443
if (gridModel && Array.isArray(gridModel)) {
440-
result.table.columns = gridModel.map((item: any) => ({
441-
width: typeof item === 'number' ? item : (item?.col ?? item?.width),
442-
}));
444+
const columns = gridModel
445+
.map((item: any) => (typeof item === 'number' ? item : (item?.col ?? item?.width)))
446+
.filter((width: unknown): width is number => typeof width === 'number' && Number.isFinite(width))
447+
.map((width: number) => ({ width }));
448+
if (columns.length > 0) {
449+
result.table.columns = columns;
450+
}
451+
}
452+
453+
if ((pmAttrs as any)?.needsTableStyleNormalization === true) {
454+
const ext = isRecord(result.ext) ? { ...result.ext } : {};
455+
const superdocExt = isRecord(ext.superdoc) ? { ...(ext.superdoc as Record<string, unknown>) } : {};
456+
superdocExt.needsTableStyleNormalization = true;
457+
result.ext = {
458+
...ext,
459+
superdoc: superdocExt,
460+
};
443461
}
444462

445463
return result;
@@ -986,6 +1004,82 @@ function resolveNodeId(pmNode: ProseMirrorNode): string | undefined {
9861004
return typeof id === 'string' && id.length > 0 ? id : undefined;
9871005
}
9881006

1007+
function isRecord(value: unknown): value is Record<string, unknown> {
1008+
return value !== null && typeof value === 'object' && !Array.isArray(value);
1009+
}
1010+
1011+
function extractTableProps(
1012+
attrs: TableAttrs | undefined,
1013+
pmAttrs: Record<string, unknown>,
1014+
): SDTable['table']['props'] | undefined {
1015+
const tableProps = attrs?.tableProperties as Record<string, unknown> | null | undefined;
1016+
const props: NonNullable<SDTable['table']['props']> = {};
1017+
let hasProps = false;
1018+
1019+
const width = extractTableWidth((tableProps as any)?.tableWidth ?? (pmAttrs as any)?.tableWidth);
1020+
if (width) {
1021+
props.width = width;
1022+
hasProps = true;
1023+
}
1024+
1025+
const layout = (tableProps as any)?.tableLayout ?? (pmAttrs as any)?.tableLayout;
1026+
if (layout === 'fixed' || layout === 'autofit') {
1027+
props.layout = layout;
1028+
hasProps = true;
1029+
}
1030+
1031+
const alignment = mapTableAlignmentToSD((tableProps as any)?.justification ?? (pmAttrs as any)?.justification);
1032+
if (alignment) {
1033+
props.alignment = alignment;
1034+
hasProps = true;
1035+
}
1036+
1037+
return hasProps ? props : undefined;
1038+
}
1039+
1040+
function mapTableAlignmentToSD(value: unknown): NonNullable<SDTable['table']['props']>['alignment'] | undefined {
1041+
if (typeof value !== 'string') return undefined;
1042+
switch (value) {
1043+
case 'start':
1044+
case 'left':
1045+
return 'left';
1046+
case 'end':
1047+
case 'right':
1048+
return 'right';
1049+
case 'center':
1050+
case 'inside':
1051+
case 'outside':
1052+
return value;
1053+
default:
1054+
return undefined;
1055+
}
1056+
}
1057+
1058+
function extractTableWidth(width: unknown): NonNullable<SDTable['table']['props']>['width'] | undefined {
1059+
if (!isRecord(width)) return undefined;
1060+
1061+
const type = typeof width.type === 'string' ? width.type.toLowerCase() : undefined;
1062+
const value =
1063+
typeof width.value === 'number' ? width.value : typeof width.width === 'number' ? width.width : undefined;
1064+
1065+
if (type === 'auto') {
1066+
return { kind: 'auto' };
1067+
}
1068+
if (type === 'nil' || type === 'none') {
1069+
return { kind: 'none' };
1070+
}
1071+
if (type === 'pct' && typeof value === 'number' && Number.isFinite(value)) {
1072+
return { kind: 'percent', value };
1073+
}
1074+
if (type === 'dxa' && typeof value === 'number' && Number.isFinite(value)) {
1075+
return { kind: 'points', value: value / 20 };
1076+
}
1077+
if (typeof value === 'number' && Number.isFinite(value)) {
1078+
return { kind: 'points', value };
1079+
}
1080+
return undefined;
1081+
}
1082+
9891083
// ---------------------------------------------------------------------------
9901084
// Helpers: paragraph properties extraction
9911085
// ---------------------------------------------------------------------------

packages/super-editor/src/document-api-adapters/structural-write-engine/index.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -173,17 +173,36 @@ function validateSectionReferences(editor: Editor, fragment: SDFragment): void {
173173
}
174174
}
175175

176-
/** Recursively collects targetSectionId values from sectionBreak nodes. */
176+
/** Recursively collects targetSectionId values from sectionBreak nodes at any depth. */
177177
function collectSectionRefIds(nodes: SDContentNode[]): string[] {
178178
const refIds: string[] = [];
179-
for (const node of nodes) {
180-
if (node.kind === 'sectionBreak' && 'sectionBreak' in node) {
181-
const payload = (node as { sectionBreak: { targetSectionId?: string } }).sectionBreak;
182-
if (payload.targetSectionId) {
183-
refIds.push(payload.targetSectionId);
179+
const visit = (children: SDContentNode[]) => {
180+
for (const node of children) {
181+
if (node.kind === 'sectionBreak' && 'sectionBreak' in node) {
182+
const payload = (node as { sectionBreak: { targetSectionId?: string } }).sectionBreak;
183+
if (payload.targetSectionId) {
184+
refIds.push(payload.targetSectionId);
185+
}
186+
}
187+
// Recurse into container nodes
188+
if (node.kind === 'list') {
189+
for (const item of node.list.items) {
190+
visit(item.content);
191+
}
192+
} else if (node.kind === 'table') {
193+
for (const row of node.table.rows) {
194+
for (const cell of row.cells) {
195+
visit(cell.content);
196+
}
197+
}
198+
} else if (node.kind === 'sdt' && node.sdt.content) {
199+
visit(node.sdt.content);
200+
} else if (node.kind === 'customXml' && node.customXml.content) {
201+
visit(node.customXml.content);
184202
}
185203
}
186-
}
204+
};
205+
visit(nodes);
187206
return refIds;
188207
}
189208

packages/super-editor/src/document-api-adapters/structural-write-engine/nesting-guard.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,40 @@ import { DocumentApiAdapterError } from '../errors.js';
1212

1313
/**
1414
* Returns true if the fragment contains any table nodes (at any depth).
15+
* Recurses into list items, table cells, sdt, and customXml content.
1516
*/
1617
function fragmentContainsTable(fragment: SDFragment): boolean {
1718
const nodes: SDContentNode[] = Array.isArray(fragment) ? fragment : [fragment];
18-
return nodes.some((node) => {
19-
const kind = (node as any).kind ?? (node as any).type;
20-
return kind === 'table';
21-
});
19+
return nodes.some(nodeContainsTable);
20+
}
21+
22+
function nodeContainsTable(node: SDContentNode): boolean {
23+
const kind = (node as any).kind ?? (node as any).type;
24+
if (kind === 'table') return true;
25+
26+
const children = getChildContentNodes(node);
27+
return children.some(nodeContainsTable);
28+
}
29+
30+
/** Extracts nested SDContentNode children from container nodes. */
31+
function getChildContentNodes(node: SDContentNode): SDContentNode[] {
32+
const children: SDContentNode[] = [];
33+
if (node.kind === 'list') {
34+
for (const item of node.list.items) {
35+
children.push(...item.content);
36+
}
37+
} else if (node.kind === 'table') {
38+
for (const row of node.table.rows) {
39+
for (const cell of row.cells) {
40+
children.push(...cell.content);
41+
}
42+
}
43+
} else if (node.kind === 'sdt' && node.sdt.content) {
44+
children.push(...node.sdt.content);
45+
} else if (node.kind === 'customXml' && node.customXml.content) {
46+
children.push(...node.customXml.content);
47+
}
48+
return children;
2249
}
2350

2451
/**

0 commit comments

Comments
 (0)