Ok, well I solved it myself so I will put my solution here. I decided to work on the DOM rather than the html as a string, and then I can grab the innerHTML as a last step. The code is slightly bulky, but the idea is:
Walk the DOM tree of the element, saving data for each node into an array (i.e. linear, not a tree). For element nodes, store both a "startelem" and "endelem" in the array, equivalent to start tags and end tags. Also take note of each element's computed "display" property (e.g. inline, block, etc), and put that in both the items in the array. (For all nodes, I also store the depth into the tree, but it doesn't appear that I need to use this).
For text nodes, take note of whether it is a regular text node, all whitespace, or an empty string.
Walk the array, and for "whitespace" text nodes, look at the previous and next item in the array. If either of them are display:inline, leave the node as a single space. If not, change the text node to be an empty string.
After that, doing an innerHTML on the element will not have the extra spaces, and, to the best I can tell, the appearance in the browser of the element will be unchanged.
Here is the code:
var stripUnneededTextNodes= function (elem) {
var array = [];
addNodeAndChildrenToArray(elem, 1, array);
for (var i=1; i<array.length-1; i++) {
if (array[i].type == "whitespace") {
if (array[i-1].display == "inline" && array[i+1].display == "inline") {
array[i].node.nodeValue = ' ';
}
else {
array[i].node.nodeValue = '';
array[i].killed = true;
}
delete array[i].node;
}
else if (array[i].type == "text") {
var val = array[i].node.nodeValue;
if (val.charAt(0) == ' ' && array[i-1].display != "inline") {
array[i].node.nodeValue = val = val.substring(1);
}
if (val.charAt(val.length-1) == ' ' && array[i+1].display != "inline") {
array[i].node.nodeValue = val.substring(0, val.length-1);
}
delete array[i].node;
}
}
};
var addNodeAndChildrenToArray = function (node, depth, array) {
switch (node.nodeType) {
case 1: { // ELEMENT_NODE
var display = document.defaultView.getComputedStyle (node, null).display;
array.push ({type: "startelem", tag: node.tagName, display: display, depth: depth});
if (node.childNodes && node.childNodes.length != 0) {
for (var i=0; i<node.childNodes.length; i++)
addNodeAndChildrenToArray(node.childNodes.item(i), depth+1, array);
}
array.push ({type: "endelem", tag: node.tagName, display: display, depth: depth});
}
break;
case 3: { //TEXT_NODE
var newVal = node.nodeValue.replace(/\s+/g, ' ');
node.nodeValue = newVal;
if (newVal == ' ')
array.push ({type: "whitespace", node: node, depth: depth});
else if (newVal == '')
array.push ({type: "emptytext", depth: depth});
else
array.push ({type: "text", node: node, display: "inline", depth: depth});
}
break;
}
};