fix(word-html): render tracked changes, rotated cell text, and cell noWrap

Render-comparison testing found several cell/revision rendering gaps:

- Tracked insertions (<w:ins>) previously rendered as plain text, losing
  the author annotation. Now wrap in a .track-ins span with underline +
  green color, with the author name in a tooltip.
- Tracked deletions (<w:del>) were dropped entirely, leaving the
  reviewer unable to see what was removed. Now render the deleted text
  inside a .track-del span with strikethrough + red color.
- Cell <w:textDirection> btLr/tbRl was ignored — text stayed horizontal
  where Word rotates 90°. Emit CSS writing-mode:vertical-rl; btLr adds
  a 180° rotation to flip the reading direction.
- Cell <w:noWrap/> was dropped — now emits white-space:nowrap so cell
  content doesn't wrap.
This commit is contained in:
zmworm 2026-04-17 02:08:33 +08:00
parent ee0d067e1e
commit af7fca565e
2 changed files with 34 additions and 2 deletions

View file

@ -1165,6 +1165,25 @@ public partial class WordHandler
parts.Add($"width:{w / 50.0:0.#}%");
}
// Cell text direction (tcDir): rotate text 90° or 270° via CSS writing-mode + transform
// Common values: btLr (bottom→top, left→right = 90° CCW), tbRl (top→bottom, right→left = 90° CW)
var tcDir = tcPr.GetFirstChild<TextDirection>()?.Val?.InnerText;
if (tcDir != null)
{
var wm = tcDir switch
{
"btLr" => "vertical-rl;transform:rotate(180deg)", // read bottom-up
"tbRl" => "vertical-rl", // read top-down
"lrTb" or null => null, // default horizontal
_ => null,
};
if (wm != null) parts.Add($"writing-mode:{wm}");
}
// Cell noWrap — prevents content wrapping within the cell
if (tcPr.NoWrap != null)
parts.Add("white-space:nowrap");
// Padding — add vertical compensation for CSS line-height:1 clipping glyph ascenders
const double CellPadVComp = 3.0; // pt
var margins = tcPr?.TableCellMargin;

View file

@ -93,13 +93,26 @@ public partial class WordHandler
}
else if (child.LocalName is "ins" or "moveTo")
{
// Tracked insertions — render their child runs
// Tracked insertions — underline to match Word's default revision mark style
var author = child.GetAttributes().FirstOrDefault(a => a.LocalName == "author").Value;
var authorAttr = string.IsNullOrEmpty(author) ? "" : $" title=\"Inserted by {HtmlEncodeAttr(author)}\"";
sb.Append($"<span class=\"track-ins\" style=\"text-decoration:underline;color:#2E7D32\"{authorAttr}>");
foreach (var insRun in child.Elements<Run>())
RenderRunHtml(sb, insRun, para);
sb.Append("</span>");
}
else if (child.LocalName is "del" or "moveFrom")
{
// Tracked deletions — skip (deleted content should not be displayed)
// Tracked deletions — strikethrough with color, preserving the deleted text
// The delText inside del runs carries the actual deleted content; we render it so
// a reader of the preview can see what was removed.
var author = child.GetAttributes().FirstOrDefault(a => a.LocalName == "author").Value;
var authorAttr = string.IsNullOrEmpty(author) ? "" : $" title=\"Deleted by {HtmlEncodeAttr(author)}\"";
var delText = string.Concat(child.Descendants()
.Where(e => e.LocalName == "delText" || e.LocalName == "t")
.Select(e => e.InnerText));
if (!string.IsNullOrEmpty(delText))
sb.Append($"<span class=\"track-del\" style=\"text-decoration:line-through;color:#C62828\"{authorAttr}>{HtmlEncode(delText)}</span>");
}
else if (child is Hyperlink hyperlink)
{