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
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 @@ -535,11 +535,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 +569,14 @@ export function decodeRPrFromMarks(marks) {
break;
}
case 'highlight':
if (mark.attrs.color) {
if (mark.attrs.color.toLowerCase() === 'transparent') {
runProperties.highlight = { 'w:val': 'none' };
} else {
runProperties.highlight = { 'w:val': mark.attrs.color };
}
if (mark.attrs.color && mark.attrs.color.toLowerCase() !== 'transparent') {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve explicit w:highlight="none" overrides

The new mark.attrs.color.toLowerCase() !== 'transparent' guard drops all transparent highlight marks, but this codebase uses transparent to represent an explicit OOXML off value (getHighLightValue maps <w:highlight w:val="none"/> to transparent in the same file). That means an imported run with explicit highlight negation can no longer be reconstructed by decodeRPrFromMarks, so when run properties are recomputed (especially during run splitting/rewrites) the highlight key can be lost and style-inherited highlight may reappear in exported DOCX.

Useful? React with 👍 / 👎.

// SD-2912: the "transparent" highlight mark is synthesized from `<w:shd val="clear"
// fill="auto"/>` shading on import (see encodeMarksFromRPr's shading case). Emitting
// an explicit `<w:highlight w:val="none"/>` for it injects a new element on every
// run that wasn't in the source. The shading itself round-trips independently via
// its own `<w:shd>` element, so no information is lost by skipping the highlight
// emit for the transparent case.
runProperties.highlight = { 'w:val': mark.attrs.color };
}
break;
case 'link':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,10 @@ describe('decodeRPrFromMarks', () => {
{ type: 'strike', attrs: { value: true } },
];
const rPr = decodeRPrFromMarks(marks);
expect(rPr).toEqual({ bold: true, boldCs: true, italic: true, italicCs: true, strike: true });
// SD-2912: `boldCs`/`italicCs` are independent OOXML properties and are no longer
// auto-propagated from the latin bold/italic marks. They round-trip via the run's
// stored runProperties when the source rPr actually contained them.
expect(rPr).toEqual({ bold: true, italic: true, strike: true });
});

it('should decode textStyle marks for color and fontSize', () => {
Expand Down Expand Up @@ -487,15 +490,42 @@ describe('decodeRPrFromMarks', () => {
const rPr = decodeRPrFromMarks(marks);
expect(rPr).toEqual({ styleId: 'Hyperlink' });
});

// SD-2912: redundant default emissions on round-trip.
// OOXML default for bCs/iCs is OFF and for w:highlight is "none" — emitting them
// explicitly is byte noise that wasn't in the source. Word treats absence and explicit
// off as identical, but the export should not introduce elements that weren't there.

it('does not propagate boldCs when the bold mark is off (SD-2912)', () => {
const marks = [{ type: 'bold', attrs: { value: false } }];
const rPr = decodeRPrFromMarks(marks);
expect(rPr.bold).toBe(false);
expect(rPr.boldCs).toBeUndefined();
});

it('does not propagate italicCs when the italic mark is off (SD-2912)', () => {
const marks = [{ type: 'italic', attrs: { value: false } }];
const rPr = decodeRPrFromMarks(marks);
expect(rPr.italic).toBe(false);
expect(rPr.italicCs).toBeUndefined();
});

it('does not emit highlight when the highlight mark is transparent (SD-2912)', () => {
const marks = [{ type: 'highlight', attrs: { color: 'transparent' } }];
const rPr = decodeRPrFromMarks(marks);
expect(rPr.highlight).toBeUndefined();
});
});

describe('marks encoding/decoding round-trip', () => {
it('should correctly round-trip basic properties', () => {
// SD-2912: `boldCs`/`italicCs` are not represented as PM marks (they are independent
// OOXML properties tracked on the run's stored runProperties). The encode/decode mark
// round-trip therefore lossily drops them at the mark layer; preservation through a
// full DOCX round-trip happens via the run-properties passthrough path.
const initialRPr = {
bold: true,
boldCs: true,
italic: true,
italicCs: true,
strike: true,
underline: { 'w:val': 'single', 'w:color': 'auto' },
color: { val: 'FF0000' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,17 @@ import {
} from '@extensions/paragraph/resolvedPropertiesCache.js';
import { collectChangedRangesThroughTransactions } from '@utils/rangeUtils.js';

// SD-2912: `boldCs` / `italicCs` are NOT marks. The OOXML companions for complex-script
// bold/italic are independent properties (ECMA-376 §17.3.2.1, §17.3.2.16) carried by the
// run's stored runProperties. Listing them here caused the plugin to overwrite their
// preserved value on every appendTransaction, which combined with the auto-propagation in
// `decodeRPrFromMarks` injected a `<w:bCs/>` / `<w:iCs/>` element into every run on round-
// trip even when the source rPr had none. Keeping them out of this set lets the existing-
// runProperties branch in `getInlineRunProperties` preserve them verbatim.
const RUN_PROPERTIES_DERIVED_FROM_MARKS = new Set([
'strike',
'italic',
'italicCs',
'bold',
'boldCs',
'underline',
'highlight',
'textTransform',
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import { dirname, join } from 'path';
import { fileURLToPath } from 'node:url';
import { promises as fs } from 'fs';
import { Editor } from '@core/Editor.js';
import DocxZipper from '@core/DocxZipper.js';
import { initTestEditor } from '../helpers/helpers.js';

const __dirname = dirname(fileURLToPath(import.meta.url));

const countOccurrences = (haystack, needle) => {
let n = 0;
let i = 0;
while ((i = haystack.indexOf(needle, i)) !== -1) {
n++;
i += needle.length;
}
return n;
};

async function roundTripCounts(fixtureFileName) {
const docxPath = join(__dirname, '../data', fixtureFileName);
const docxBuffer = await fs.readFile(docxPath);

const inputZipper = new DocxZipper();
const inputEntries = await inputZipper.getDocxData(docxBuffer, true);
const inputDocXml = inputEntries.find((e) => e.name === 'word/document.xml').content;

const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(docxBuffer, true);
const { editor } = await initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true });

const exportedBuffer = await editor.exportDocx({ isFinalDoc: false });
const exportedZipper = new DocxZipper();
const exportedEntries = await exportedZipper.getDocxData(exportedBuffer, true);
const exportDocXml = exportedEntries.find((e) => e.name === 'word/document.xml').content;

return {
input: {
bCs: countOccurrences(inputDocXml, '<w:bCs'),
iCs: countOccurrences(inputDocXml, '<w:iCs'),
highlight: countOccurrences(inputDocXml, '<w:highlight'),
},
output: {
bCs: countOccurrences(exportDocXml, '<w:bCs'),
iCs: countOccurrences(exportDocXml, '<w:iCs'),
highlight: countOccurrences(exportDocXml, '<w:highlight'),
},
};
}

describe('SD-2912 — DOCX round-trip does not inject redundant default rPr elements', () => {
it('does not add `<w:bCs/>` elements that were not in the source document.xml', async () => {
const { input, output } = await roundTripCounts('sd-2912-pgmar-roundtrip.docx');
expect(input.bCs).toBe(0);
expect(output.bCs).toBe(0);
});

it('does not add `<w:iCs/>` elements that were not in the source document.xml', async () => {
const { input, output } = await roundTripCounts('sd-2912-pgmar-roundtrip.docx');
expect(input.iCs).toBe(0);
expect(output.iCs).toBe(0);
});

it('does not add `<w:highlight w:val="none"/>` elements that were not in the source document.xml', async () => {
const { input, output } = await roundTripCounts('sd-2912-pgmar-roundtrip.docx');
expect(input.highlight).toBe(0);
expect(output.highlight).toBe(0);
});
});
Loading