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
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,8 @@ describe('getMarksFromSelection', () => {

const result = getSelectionFormattingState(cursorState);

expect(result.inlineRunProperties).toEqual({ bold: true, boldCs: true });
// SD-2912: `boldCs` is no longer auto-propagated from the bold mark.
expect(result.inlineRunProperties).toEqual({ bold: true });
expect(result.inlineMarks.some((mark) => mark.type.name === 'bold')).toBe(true);
expect(result.resolvedMarks.some((mark) => mark.type.name === 'bold')).toBe(true);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ describe('syncParagraphRunProperties', () => {
italic: true,
styleId: 'Heading1Char',
bold: true,
boldCs: true,
// SD-2912: `boldCs` is no longer auto-propagated from the bold mark — see
// `decodeRPrFromMarks` in super-converter/styles.js.
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,44 @@ export const ensureSectionLayoutDefaults = (sectPr, converter) => {
return sectPr;
};

/**
* Walk an XML JSON tree in place and normalize every `<w:pgMar>` element's
* numeric attributes to integer twips.
*
* ECMA-376 §17.6.11 requires `ST_TwipsMeasure` values to be non-negative whole
* numbers when expressed as raw twips, but some authoring pipelines emit
* float-valued twips like `w:top="168.160400390625"` that strict consumers
* reject. SuperDoc preserves those floats verbatim on the paragraph-level
* sectPr passthrough path. This pass is the single normalization point —
* applied once at the export root — so we produce schema-valid output
* regardless of which path the pgMar values reached the tree through
* (body sectPr → `pageMargins` → `inchesToTwips`, or paragraph-level
* passthrough sectPr that preserved source attrs).
*
* Idempotent. Mutates the tree in place; returns nothing. SD-2912.
*
* @param {{ name?: string, attributes?: Record<string, unknown>, elements?: Array<unknown> } | null | undefined} node
* @returns {void}
*/
export const normalizePgMarTwipsInTree = (node) => {
if (!node || typeof node !== 'object') return;
if (node.name === 'w:pgMar' && node.attributes && typeof node.attributes === 'object') {
for (const key of Object.keys(node.attributes)) {
const value = node.attributes[key];
if (value == null) continue;
const serialized = String(value).trim();
if (!serialized) continue;
const num = Number(serialized);
if (Number.isFinite(num) && !/^-?\d+$/.test(serialized)) {
node.attributes[key] = String(Math.round(num));
}
}
}
if (Array.isArray(node.elements)) {
for (const child of node.elements) normalizePgMarTwipsInTree(child);
}
};

export const isLineBreakOnlyRun = (node) => {
if (!node) return false;
if (node.type === 'lineBreak' || node.type === 'hardBreak') return true;
Expand Down Expand Up @@ -402,6 +440,12 @@ function translateDocumentNode(params) {
attributes,
};

// SD-2912: normalize every <w:pgMar> in the final tree to integer twips,
// catching both the body sectPr path (already integer-correct via
// inchesToTwips) and the paragraph-level passthrough sectPr path that
// preserves source attrs verbatim.
normalizePgMarTwipsInTree(node);

return [node, params];
}

Expand Down Expand Up @@ -568,6 +612,9 @@ function translateMark(mark) {
break;
case 'highlight': {
const highlightValue = attrs.color ?? attrs.highlight ?? null;
if (String(highlightValue).trim().toLowerCase() === 'transparent' && !attrs.ooxmlHighlightClear) {
return {};
}
const translated = wHighlightTranslator.decode({ node: { attrs: { highlight: highlightValue } } });
return translated || {};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { describe, it, expect } from 'vitest';
import { normalizePgMarTwipsInTree, processOutputMarks } from './exporter.js';

// SD-2912: <w:pgMar> attributes must be integer twips per ECMA-376 §17.6.11
// (ST_TwipsMeasure). Some documents carry float-valued twips like
// `w:top="168.160400390625"` that pass through the import → export pipeline
// verbatim on paragraph-level sectPr passthrough; strict consumers reject the
// result. This helper is the single normalization point: it walks the export
// XML JSON tree and rounds every numeric pgMar attribute to an integer.

describe('normalizePgMarTwipsInTree', () => {
it('does not throw on undefined input', () => {
expect(() => normalizePgMarTwipsInTree(undefined)).not.toThrow();
});

it('does not throw on null input', () => {
expect(() => normalizePgMarTwipsInTree(null)).not.toThrow();
});

it('leaves a tree without any w:pgMar element unchanged', () => {
const tree = {
name: 'w:document',
elements: [{ name: 'w:body', elements: [{ name: 'w:p', elements: [] }] }],
};
const before = JSON.stringify(tree);
normalizePgMarTwipsInTree(tree);
expect(JSON.stringify(tree)).toBe(before);
});

it('rounds a single float pgMar attribute to an integer twips value', () => {
const tree = { name: 'w:pgMar', attributes: { 'w:top': '168.160400390625' } };
normalizePgMarTwipsInTree(tree);
expect(tree.attributes['w:top']).toBe('168');
});

it('rounds every numeric pgMar attribute, leaves already-integer values exact', () => {
const tree = {
name: 'w:pgMar',
attributes: {
'w:top': '168.160400390625',
'w:bottom': '146.0200023651123',
'w:left': '352.31998443603516',
'w:right': '663.9990234375',
'w:gutter': '0',
'w:header': '720',
},
};
normalizePgMarTwipsInTree(tree);
expect(tree.attributes).toEqual({
'w:top': '168',
'w:bottom': '146',
'w:left': '352',
'w:right': '664',
'w:gutter': '0',
'w:header': '720',
});
});

it('canonicalizes decimal pgMar tokens even when the numeric value is integral', () => {
const tree = {
name: 'w:pgMar',
attributes: {
'w:top': '168.0',
'w:left': '352.000000',
'w:right': '663.9990234375',
'w:header': '720',
},
};

normalizePgMarTwipsInTree(tree);

expect(tree.attributes).toEqual({
'w:top': '168',
'w:left': '352',
'w:right': '664',
'w:header': '720',
});
});

it('walks into nested elements and normalizes pgMar attrs at any depth', () => {
const tree = {
name: 'w:document',
elements: [
{
name: 'w:body',
elements: [
{
name: 'w:p',
elements: [
{
name: 'w:pPr',
elements: [
{
name: 'w:sectPr',
elements: [{ name: 'w:pgMar', attributes: { 'w:top': '146.0200023651123' } }],
},
],
},
],
},
{ name: 'w:sectPr', elements: [{ name: 'w:pgMar', attributes: { 'w:bottom': '352.31998443603516' } }] },
],
},
],
};
normalizePgMarTwipsInTree(tree);
const firstPgMar = tree.elements[0].elements[0].elements[0].elements[0].elements[0];
const secondPgMar = tree.elements[0].elements[1].elements[0];
expect(firstPgMar.attributes['w:top']).toBe('146');
expect(secondPgMar.attributes['w:bottom']).toBe('352');
});

it('is idempotent — re-running on already-normalized values is a no-op', () => {
const tree = { name: 'w:pgMar', attributes: { 'w:top': '168.5' } };
normalizePgMarTwipsInTree(tree);
const afterFirst = { ...tree.attributes };
normalizePgMarTwipsInTree(tree);
expect(tree.attributes).toEqual(afterFirst);
expect(tree.attributes['w:top']).toBe('169');
});

it('ignores non-numeric attribute values (defensive against future OOXML extensions)', () => {
const tree = { name: 'w:pgMar', attributes: { 'w:top': '168', 'w:custom': 'auto' } };
normalizePgMarTwipsInTree(tree);
expect(tree.attributes['w:custom']).toBe('auto');
});

it('does not affect attributes on elements other than w:pgMar', () => {
const tree = {
name: 'w:document',
elements: [
{ name: 'w:pgSz', attributes: { 'w:w': '12240.5', 'w:h': '15840.7' } },
{ name: 'w:pgMar', attributes: { 'w:top': '168.5' } },
],
};
normalizePgMarTwipsInTree(tree);
expect(tree.elements[0].attributes).toEqual({ 'w:w': '12240.5', 'w:h': '15840.7' });
expect(tree.elements[1].attributes['w:top']).toBe('169');
});
});

describe('processOutputMarks highlight clear export', () => {
it('does not emit highlight XML for plain transparent highlight marks', () => {
const outputMarks = processOutputMarks([{ type: 'highlight', attrs: { color: 'transparent' } }]);

expect(outputMarks).toEqual([{}]);
});

it('emits highlight none only for imported explicit highlight clears', () => {
const outputMarks = processOutputMarks([
{ type: 'highlight', attrs: { color: 'transparent', ooxmlHighlightClear: true } },
]);

expect(outputMarks).toEqual([{ name: 'w:highlight', attributes: { 'w:val': 'none' } }]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ export function encodeMarksFromRPr(runProperties, docx) {

const marks = [];
const textStyleAttrs = {};
let highlightColor = null;
/** @type {{ color: string, ooxmlHighlightClear?: boolean } | null} */
let highlightAttrs = null;
let hasHighlightTag = false;
Object.keys(runProperties).forEach((key) => {
const value = runProperties[key];
Expand Down Expand Up @@ -134,7 +135,10 @@ export function encodeMarksFromRPr(runProperties, docx) {
const color = getHighLightValue(value);
if (color) {
hasHighlightTag = true;
highlightColor = color;
highlightAttrs = { color };
if (color.toLowerCase() === 'transparent' && String(value?.['w:val']).toLowerCase() === 'none') {
highlightAttrs.ooxmlHighlightClear = true;
}
}
break;
case 'shading': {
Expand All @@ -144,11 +148,11 @@ export function encodeMarksFromRPr(runProperties, docx) {
const fill = value['fill'];
const shdVal = value['val'];
if (fill && String(fill).toLowerCase() !== 'auto') {
highlightColor = `#${String(fill).replace('#', '')}`;
highlightAttrs = { color: `#${String(fill).replace('#', '')}` };
} else if (typeof shdVal === 'string') {
const normalized = shdVal.toLowerCase();
if (normalized === 'clear' || normalized === 'nil' || normalized === 'none') {
highlightColor = 'transparent';
highlightAttrs = { color: 'transparent' };
}
}
break;
Expand All @@ -175,8 +179,8 @@ export function encodeMarksFromRPr(runProperties, docx) {
marks.push({ type: 'textStyle', attrs: textStyleAttrs });
}

if (highlightColor) {
marks.push({ type: 'highlight', attrs: { color: highlightColor } });
if (highlightAttrs) {
marks.push({ type: 'highlight', attrs: highlightAttrs });
}

return marks;
Expand Down Expand Up @@ -535,11 +539,14 @@ export function decodeRPrFromMarks(marks) {
case 'italic':
case 'bold':
runProperties[type] = mark.attrs.value !== '0' && mark.attrs.value !== false;
if (type === 'bold') {
runProperties.boldCs = runProperties.bold;
} else if (type === 'italic') {
runProperties.italicCs = runProperties.italic;
}
// SD-2912: do NOT auto-propagate `boldCs` / `italicCs` from the latin
// bold/italic mark. The complex-script companion is an independent OOXML
// property (ECMA-376 §17.3.2). Auto-propagating it injects elements that
// weren't in the source rPr — every run gets a `<w:bCs/>` regardless of
// whether the original `<w:rPr>` contained one. When the source genuinely
// had `<w:bCs/>`, it round-trips via the run's stored runProperties
// (preserved by the plugin's existing-keys branch — see the matching
// SD-2912 change in `calculateInlineRunPropertiesPlugin.js`).
break;
case 'underline': {
const { underlineType, underlineColor, underlineThemeColor, underlineThemeTint, underlineThemeShade } =
Expand All @@ -566,12 +573,13 @@ export function decodeRPrFromMarks(marks) {
break;
}
case 'highlight':
if (mark.attrs.color) {
if (mark.attrs.color.toLowerCase() === 'transparent') {
if (!mark.attrs.color) break;
if (mark.attrs.color.toLowerCase() === 'transparent') {
if (mark.attrs.ooxmlHighlightClear) {
runProperties.highlight = { 'w:val': 'none' };
} else {
runProperties.highlight = { 'w:val': mark.attrs.color };
}
} else {
runProperties.highlight = { 'w:val': mark.attrs.color };
}
break;
case 'link':
Expand Down
Loading
Loading