Skip to content

Commit a927870

Browse files
committed
change surgical formatting to use w:rPr
1 parent cdd3be7 commit a927870

2 files changed

Lines changed: 206 additions & 124 deletions

File tree

src/taskpane/modules/reconciliation/oxml-engine.js

Lines changed: 78 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -915,20 +915,15 @@ function processRunForFormatting(run, paragraph, charOffset, textSpans, formatHi
915915

916916
/**
917917
* Format REMOVAL via surgical text replacement (pure OOXML approach).
918-
* Instead of using w:rPrChange (which Word ignores via insertOoxml),
919-
* we treat format removal as a text replacement:
920-
* - Original formatted run → wrapped in w:del
921-
* - New unformatted run → wrapped in w:ins
922-
*
923-
* This appears as a surgical text replacement in track changes,
924-
* which Word properly honors.
918+
* UPDATED: Uses w:rPr modification with w:rPrChange instead of text replacement (w:del/w:ins).
919+
* This prevents the "strikeout + new text" visualization and shows a proper formatting change.
925920
*/
926921
function applyFormatRemovalAsSurgicalReplacement(xmlDoc, textSpans, existingFormatHints, serializer, author, generateRedlines = true) {
927922
let hasAnyChanges = false;
928923
const processedRuns = new Set();
929924
const dateStr = new Date().toISOString();
930925

931-
console.log(`[OxmlEngine] Surgical format removal: ${existingFormatHints.length} hints to process`);
926+
console.log(`[OxmlEngine] Surgical format removal: ${existingFormatHints.length} hints to process (using w:rPrChange)`);
932927

933928
for (const hint of existingFormatHints) {
934929
const run = hint.run;
@@ -942,96 +937,54 @@ function applyFormatRemovalAsSurgicalReplacement(xmlDoc, textSpans, existingForm
942937

943938
console.log(`[OxmlEngine] Processing run for surgical format removal, format:`, hint.format);
944939

945-
// Get the text content from this run
946-
let textContent = '';
947-
for (const child of run.childNodes) {
948-
if (child.nodeName === 'w:t') {
949-
textContent += child.textContent || '';
950-
}
951-
}
952-
953-
if (!textContent) {
954-
console.log('[OxmlEngine] Skipping run with no text content');
955-
continue;
940+
// Get or create w:rPr
941+
let rPr = run.getElementsByTagName('w:rPr')[0];
942+
if (!rPr) {
943+
rPr = xmlDoc.createElement('w:rPr');
944+
run.insertBefore(rPr, run.firstChild);
956945
}
957946

958-
const parentNode = run.parentNode;
959-
947+
// If generating redlines, track the change
960948
if (generateRedlines) {
961-
// Create w:del container with the original formatted run
962-
const delContainer = xmlDoc.createElement('w:del');
963-
delContainer.setAttribute('w:id', Math.floor(Math.random() * 1000000).toString());
964-
delContainer.setAttribute('w:author', author || 'Gemini AI');
965-
delContainer.setAttribute('w:date', dateStr);
949+
// Check if there's already a track change on this run's properties
950+
// If so, we append to it or replace is tricky. Simple approach: Add new w:rPrChange.
951+
// But OOXML usually expects one w:rPrChange.
966952

967-
// Clone the run for deletion (keeps original formatting)
968-
const deletedRun = run.cloneNode(true);
969-
970-
// Convert w:t to w:delText in the deleted run
971-
const tElements = deletedRun.getElementsByTagName('w:t');
972-
const tArray = Array.from(tElements);
973-
for (const t of tArray) {
974-
const delText = xmlDoc.createElement('w:delText');
975-
delText.setAttribute('xml:space', 'preserve');
976-
delText.textContent = t.textContent;
977-
t.parentNode.replaceChild(delText, t);
978-
}
953+
// Format: <w:rPr> ... <w:rPrChange ...> <w:rPr>...original...</w:rPr> </w:rPrChange> </w:rPr>
979954

980-
delContainer.appendChild(deletedRun);
955+
// 1. Create w:rPrChange element
956+
const rPrChange = xmlDoc.createElement('w:rPrChange');
957+
rPrChange.setAttribute('w:id', Math.floor(Math.random() * 1000000).toString());
958+
rPrChange.setAttribute('w:author', author || 'Gemini AI');
959+
rPrChange.setAttribute('w:date', dateStr);
981960

982-
// Create w:ins container with unformatted run
983-
const insContainer = xmlDoc.createElement('w:ins');
984-
insContainer.setAttribute('w:id', Math.floor(Math.random() * 1000000).toString());
985-
insContainer.setAttribute('w:author', author || 'Gemini AI');
986-
insContainer.setAttribute('w:date', dateStr);
987-
988-
// Create new run with explicit format overrides
989-
const newRun = xmlDoc.createElement('w:r');
990-
991-
const originalRPr = run.getElementsByTagName('w:rPr')[0];
992-
const newRPr = originalRPr ? originalRPr.cloneNode(true) : xmlDoc.createElement('w:rPr');
993-
994-
// Remove formatting elements and add explicit overrides
995-
applyFormatOverridesToRPr(xmlDoc, newRPr, hint.format);
996-
997-
if (newRPr.childNodes.length > 0) {
998-
newRun.appendChild(newRPr);
961+
// 2. Snapshot current rPr state (excluding any existing rPrChange)
962+
const originalRPrSnapshot = xmlDoc.createElement('w:rPr');
963+
for (const child of Array.from(rPr.childNodes)) {
964+
if (child.nodeName !== 'w:rPrChange') {
965+
originalRPrSnapshot.appendChild(child.cloneNode(true));
966+
}
999967
}
968+
rPrChange.appendChild(originalRPrSnapshot);
1000969

1001-
// Add the text element
1002-
const newT = xmlDoc.createElement('w:t');
1003-
newT.setAttribute('xml:space', 'preserve');
1004-
newT.textContent = textContent;
1005-
newRun.appendChild(newT);
1006-
1007-
insContainer.appendChild(newRun);
1008-
1009-
// Insert w:del before the original run, then w:ins after w:del
1010-
parentNode.insertBefore(delContainer, run);
1011-
parentNode.insertBefore(insContainer, run);
1012-
1013-
// Remove the original run
1014-
parentNode.removeChild(run);
1015-
1016-
console.log(`[OxmlEngine] Created surgical replacement for "${textContent}"`);
1017-
} else {
1018-
// Without redlines, remove formatting by explicit overrides on the run
1019-
let rPr = run.getElementsByTagName('w:rPr')[0];
1020-
if (!rPr) {
1021-
rPr = xmlDoc.createElement('w:rPr');
1022-
run.insertBefore(rPr, run.firstChild);
970+
// 3. Remove any existing w:rPrChange to avoid duplicates strings
971+
const existingChange = rPr.getElementsByTagName('w:rPrChange')[0];
972+
if (existingChange) {
973+
rPr.removeChild(existingChange);
1023974
}
1024975

1025-
applyFormatOverridesToRPr(xmlDoc, rPr, hint.format);
1026-
1027-
console.log(`[OxmlEngine] Removed formatting via overrides (no redlines) for "${textContent}"`);
976+
// 4. Append new w:rPrChange to rPr (usually last child of rPr)
977+
rPr.appendChild(rPrChange);
1028978
}
1029979

980+
// Apply format overrides (unbold, unitalic, etc.)
981+
applyFormatOverridesToRPr(xmlDoc, rPr, hint.format);
982+
1030983
hasAnyChanges = true;
1031984
}
1032985

1033986
if (hasAnyChanges) {
1034-
console.log('[OxmlEngine] Surgical format removal completed successfully');
987+
console.log('[OxmlEngine] Surgical format removal completed successfully (Pure Format Mode)');
1035988
return { oxml: serializer.serializeToString(xmlDoc), hasChanges: true };
1036989
}
1037990

@@ -1041,7 +994,8 @@ function applyFormatRemovalAsSurgicalReplacement(xmlDoc, textSpans, existingForm
1041994

1042995
/**
1043996
* Format ADDITION via surgical text replacement (pure OOXML approach).
1044-
* Wraps the original run in w:del and inserts a formatted w:ins run.
997+
* UPDATED: Uses w:rPr modification with w:rPrChange instead of text replacement (w:del/w:ins).
998+
* This prevents the "strikeout + new text" visualization and shows a proper formatting change.
1045999
*/
10461000
function applyFormatAdditionsAsSurgicalReplacement(xmlDoc, textSpans, formatHints, serializer, author, generateRedlines = true) {
10471001
let hasAnyChanges = false;
@@ -1106,51 +1060,51 @@ function applyFormatAdditionsAsSurgicalReplacement(xmlDoc, textSpans, formatHint
11061060
const parentNode = span.runElement.parentNode;
11071061
if (!parentNode) continue;
11081062

1063+
// PURE FORMAT CHANGE LOGIC (New)
1064+
const run = span.runElement;
1065+
1066+
// Get or create w:rPr
1067+
let rPr = run.getElementsByTagName('w:rPr')[0];
1068+
if (!rPr) {
1069+
rPr = xmlDoc.createElement('w:rPr');
1070+
run.insertBefore(rPr, run.firstChild);
1071+
}
1072+
1073+
// If generating redlines, track the change
11091074
if (generateRedlines) {
1110-
const delContainer = xmlDoc.createElement('w:del');
1111-
delContainer.setAttribute('w:id', Math.floor(Math.random() * 1000000).toString());
1112-
delContainer.setAttribute('w:author', author || 'Gemini AI');
1113-
delContainer.setAttribute('w:date', dateStr);
1114-
1115-
const deletedRun = span.runElement.cloneNode(true);
1116-
const tElements = deletedRun.getElementsByTagName('w:t');
1117-
const tArray = Array.from(tElements);
1118-
for (const t of tArray) {
1119-
const delText = xmlDoc.createElement('w:delText');
1120-
delText.setAttribute('xml:space', 'preserve');
1121-
delText.textContent = t.textContent;
1122-
t.parentNode.replaceChild(delText, t);
1123-
}
1124-
delContainer.appendChild(deletedRun);
1125-
1126-
const insContainer = xmlDoc.createElement('w:ins');
1127-
insContainer.setAttribute('w:id', Math.floor(Math.random() * 1000000).toString());
1128-
insContainer.setAttribute('w:author', author || 'Gemini AI');
1129-
insContainer.setAttribute('w:date', dateStr);
1130-
1131-
const newRun = xmlDoc.createElement('w:r');
1132-
const newRPr = buildAddedFormatRPr(xmlDoc, span.runElement, desiredFormat);
1133-
if (newRPr && newRPr.childNodes.length > 0) {
1134-
newRun.appendChild(newRPr);
1135-
}
1136-
const newT = xmlDoc.createElement('w:t');
1137-
newT.setAttribute('xml:space', 'preserve');
1138-
newT.textContent = textContent;
1139-
newRun.appendChild(newT);
1140-
insContainer.appendChild(newRun);
1141-
1142-
parentNode.insertBefore(delContainer, span.runElement);
1143-
parentNode.insertBefore(insContainer, span.runElement);
1144-
parentNode.removeChild(span.runElement);
1145-
} else {
1146-
let rPr = span.runElement.getElementsByTagName('w:rPr')[0] || null;
1147-
if (!rPr) {
1148-
rPr = xmlDoc.createElement('w:rPr');
1149-
span.runElement.insertBefore(rPr, span.runElement.firstChild);
1075+
// Check if there's already a track change on this run's properties (avoid duplicates)
1076+
// If one exists, we ideally merge, but replacing the whole rPrChange logic is cleaner
1077+
// for "State A -> State B" transition.
1078+
1079+
// 1. Create w:rPrChange element
1080+
const rPrChange = xmlDoc.createElement('w:rPrChange');
1081+
rPrChange.setAttribute('w:id', Math.floor(Math.random() * 1000000).toString());
1082+
rPrChange.setAttribute('w:author', author || 'Gemini AI');
1083+
rPrChange.setAttribute('w:date', dateStr);
1084+
1085+
// 2. Snapshot current rPr state (excluding any existing rPrChange)
1086+
const originalRPrSnapshot = xmlDoc.createElement('w:rPr');
1087+
for (const child of Array.from(rPr.childNodes)) {
1088+
if (child.nodeName !== 'w:rPrChange') {
1089+
originalRPrSnapshot.appendChild(child.cloneNode(true));
1090+
}
11501091
}
1151-
applyFormatAdditionsToRPr(xmlDoc, rPr, desiredFormat);
1092+
rPrChange.appendChild(originalRPrSnapshot);
1093+
1094+
// 3. Remove any existing w:rPrChange
1095+
const existingChange = rPr.getElementsByTagName('w:rPrChange')[0];
1096+
if (existingChange) {
1097+
rPr.removeChild(existingChange);
1098+
}
1099+
1100+
// 4. Append new w:rPrChange to rPr
1101+
rPr.appendChild(rPrChange);
11521102
}
11531103

1104+
// Apply format additions (merging with existing)
1105+
// Helper function should correctly add w:b, w:i, etc. while respecting rPrChange position
1106+
applyFormatAdditionsToRPr(xmlDoc, rPr, desiredFormat);
1107+
11541108
hasAnyChanges = true;
11551109
}
11561110

tests/verify_fix.mjs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
2+
import { JSDOM } from 'jsdom';
3+
import { applyRedlineToOxml } from '../src/taskpane/modules/reconciliation/oxml-engine.js';
4+
5+
// Global Setup
6+
const dom = new JSDOM('');
7+
global.DOMParser = dom.window.DOMParser;
8+
global.XMLSerializer = dom.window.XMLSerializer;
9+
global.document = dom.window.document;
10+
global.Node = dom.window.Node;
11+
12+
async function testSurgicalPureFormats() {
13+
console.log('\n=== Test: Surgical Pure Formats (Addition & Removal) ===');
14+
15+
const baseXml = (content) => `
16+
<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
17+
${content}
18+
</w:p>
19+
`;
20+
21+
// --- Helpers ---
22+
async function check(caseName, oxmlFragment, originalText, modifiedText, expectedChecks) {
23+
console.log(`\n--- ${caseName} ---`);
24+
const fullXml = baseXml(oxmlFragment);
25+
try {
26+
const result = await applyRedlineToOxml(fullXml, originalText, modifiedText, {
27+
author: 'Tester',
28+
generateRedlines: true
29+
});
30+
31+
const oxml = result.oxml;
32+
const hasDel = oxml.includes('<w:del ') || oxml.includes('<w:del>');
33+
const hasIns = oxml.includes('<w:ins ') || oxml.includes('<w:ins>');
34+
const hasRPrChange = oxml.includes('<w:rPrChange ') || oxml.includes('<w:rPrChange>');
35+
36+
if (!hasDel && !hasIns) {
37+
console.log("✅ PASS: No w:del/w:ins text replacements found.");
38+
} else {
39+
console.log("❌ FAIL: Found w:del/w:ins text replacements.");
40+
}
41+
42+
if (hasRPrChange) {
43+
console.log("✅ PASS: Found w:rPrChange tracking.");
44+
} else {
45+
console.log("❌ FAIL: Missing w:rPrChange tracking.");
46+
}
47+
48+
for (const check of expectedChecks) {
49+
if (oxml.includes(check.str)) {
50+
console.log(`✅ PASS: Found expected string: ${check.desc}`);
51+
} else {
52+
console.log(`❌ FAIL: Missing expected string: ${check.desc} ('${check.str}')`);
53+
}
54+
}
55+
} catch (e) {
56+
console.error(`ERROR in ${caseName}:`, e);
57+
}
58+
}
59+
60+
// --- Test Cases ---
61+
62+
// 1. ADD BOLD
63+
await check(
64+
"Add Bold",
65+
`<w:r><w:t>Hello</w:t></w:r>`,
66+
"Hello",
67+
"**Hello**",
68+
[{ str: 'w:b w:val="1"', desc: "Bold tag" }]
69+
);
70+
71+
// 2. REMOVE BOLD
72+
await check(
73+
"Remove Bold",
74+
`<w:r><w:rPr><w:b/></w:rPr><w:t>Hello</w:t></w:r>`,
75+
"Hello",
76+
"Hello", // Removed markdown -> unbold
77+
[{ str: 'w:b w:val="0"', desc: "Unbold tag" }]
78+
);
79+
80+
// 3. ADD ITALIC
81+
await check(
82+
"Add Italic",
83+
`<w:r><w:t>Hello</w:t></w:r>`,
84+
"Hello",
85+
"*Hello*",
86+
[{ str: 'w:i w:val="1"', desc: "Italic tag" }]
87+
);
88+
89+
// 4. REMOVE ITALIC
90+
await check(
91+
"Remove Italic",
92+
`<w:r><w:rPr><w:i/></w:rPr><w:t>Hello</w:t></w:r>`,
93+
"Hello",
94+
"Hello",
95+
[{ str: 'w:i w:val="0"', desc: "Unitalic tag" }]
96+
);
97+
98+
// 5. ADD STRIKETHROUGH
99+
await check(
100+
"Add Strikethrough",
101+
`<w:r><w:t>Hello</w:t></w:r>`,
102+
"Hello",
103+
"~~Hello~~",
104+
[{ str: 'w:strike w:val="1"', desc: "Strike tag" }]
105+
);
106+
107+
// 6. REMOVE STRIKETHROUGH
108+
await check(
109+
"Remove Strikethrough",
110+
`<w:r><w:rPr><w:strike/></w:rPr><w:t>Hello</w:t></w:r>`,
111+
"Hello",
112+
"Hello",
113+
[{ str: 'w:strike w:val="0"', desc: "Unstrike tag" }]
114+
);
115+
116+
// 7. ADD UNDERLINE (NOTE: Markdown doesn't have standard underline, assuming '++' or context specific, but checking generic format hint application if triggered)
117+
// Actually, our engine might map specific markdown to underline?
118+
// Let's assume input comes as format hints directly or we use a made-up syntax if supported.
119+
// Wait, standard markdown doesn't do underline.
120+
// I will check specific tag application logic by mocking the internal call if needed, but for now let's verify if '++' maps to underline?
121+
// Checking previous files... markdown-processor.js might have clues.
122+
// Assuming ++ is underline for now based on some common extensions or just testing the engine capability.
123+
124+
}
125+
126+
(async () => {
127+
await testSurgicalPureFormats();
128+
})();

0 commit comments

Comments
 (0)