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
23 changes: 7 additions & 16 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ All notable changes to this project will be documented in this file.
page-2/page-3 first ink moved from row 115 to 99 px (Word 100, LibreOffice 99),
and a same-environment filtered rerun improved mean SSIM 0.69467 → 0.72874
and mean ink F1 0.57203 → 0.70363.
- **Cached TOC field-result hyperlinks now match Word's black, undecorated presentation**
(issue #427): measured Word output contains zero blue pixels across the cached TOC entries even
though their runs reference the blue, underlined `Hyperlink` character style; an ordinary link
with the same styles remains decorated. The converter now derives that exception from the
existing complex-field annotations: `FieldRetriever` recognizes `TOC`, and run styling removes
only hyperlink color and underline while inside its cached result. A generated DOCX and a native
cross-paragraph field-scope test prevent paragraph-style, anchor-name, and blanket-link fixes.
- **Mobile Chrome no longer garbles the arcade/observatory animations with
extra wrapped rows, and the converter opts document text out of
device-driven inflation** — on Android the demo's 92-cell frame rows render
Expand Down Expand Up @@ -331,22 +338,6 @@ All notable changes to this project will be documented in this file.
that Docxodus applies table-style conditional formatting from Word's per-row/per-cell
`w:cnfStyle` hints rather than deriving band membership from `w:tblLook`, so a
hand-authored table without them renders unshaded.
- **TOC hyperlink styling attributed to the reference implementation, not the renderer**
(issue #397, `npm/tests/toc-line-geometry.spec.ts` + `visual-parity/corpus.ts`):
the `fields-and-tabs` benchmark case named two residuals, and they have opposite
answers. Entry line-box height was a renderer bug and is fixed (issue #396 —
entries were displaced by a growing 7.3/11.0/14.7px and now land within 0.12px of
LibreOffice). Hyperlink appearance is not: the entry runs carry
`<w:rStyle w:val="Hyperlink"/>` and that character style declares `w:color="0563C1"`
with `w:u w:val="single"`. Docxodus paints the declared colour byte for byte;
LibreOffice paints the entries black, dropping a style the run explicitly
references. `w:hyperlink` is a link, not a style, so the output is **not** changed
to match the comparison implementation and the case's disposition moves
`renderer-bug` → `reference-deviation`. A generated regression pins entry line
geometry and hyperlink appearance **separately**, and — because "we match Word"
would otherwise be indistinguishable from decorating every hyperlink by default —
includes an otherwise identical entry with no `w:rStyle` that must come out
undecorated. Documented in `docs/ooxml_corner_cases.md`.

### Fixed
- **Border colour resolves `w:themeColor` instead of its cached literal** (issue #399,
Expand Down
72 changes: 72 additions & 0 deletions Docxodus.Tests/FieldRetrieverTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Collections.Generic;
using System.IO;
using System.Linq;
using DocumentFormat.OpenXml.Packaging;
using Wp = DocumentFormat.OpenXml.Wordprocessing;
using Xunit;

namespace Docxodus.Tests;

public class FieldRetrieverTests
{
[Fact]
public void IsFieldResultTracksTocAcrossParagraphsWithoutLeakingPastEnd()
{
using var stream = new MemoryStream();
using var document = WordprocessingDocument.Create(
stream,
DocumentFormat.OpenXml.WordprocessingDocumentType.Document);
var main = document.AddMainDocumentPart();
main.Document = new Wp.Document(
new Wp.Body(
new Wp.Paragraph(
new Wp.Run(new Wp.FieldChar { FieldCharType = Wp.FieldCharValues.Begin }),
new Wp.Run(new Wp.FieldCode(" TOC \\o \"1-3\" \\h ")),
new Wp.Run(new Wp.FieldChar { FieldCharType = Wp.FieldCharValues.Separate }),
new Wp.Hyperlink(new Wp.Run(new Wp.Text("First cached entry")))
{
Anchor = "_Toc1",
}),
new Wp.Paragraph(
new Wp.Hyperlink(
new Wp.Run(new Wp.Text("Second cached entry")),
new Wp.Run(new Wp.FieldChar { FieldCharType = Wp.FieldCharValues.Begin }),
new Wp.Run(new Wp.FieldCode(" PAGEREF _Toc2 \\h ")),
new Wp.Run(new Wp.FieldChar { FieldCharType = Wp.FieldCharValues.Separate }),
new Wp.Run(new Wp.Text("2")),
new Wp.Run(new Wp.FieldChar { FieldCharType = Wp.FieldCharValues.End }))
{
Anchor = "_Toc2",
},
new Wp.Run(new Wp.FieldChar { FieldCharType = Wp.FieldCharValues.End })),
new Wp.Paragraph(
new Wp.Hyperlink(new Wp.Run(new Wp.Text("Ordinary link")))
{
Anchor = "_Ordinary",
})));
main.Document.Save();

FieldRetriever.AnnotateWithFieldInfo(main);
var root = main.GetXDocument().Root!;
var firstCachedRun = root.Descendants(W.r)
.Single(run => run.Descendants(W.t).Any(text => text.Value == "First cached entry"));
var secondCachedRun = root.Descendants(W.r)
.Single(run => run.Descendants(W.t).Any(text => text.Value == "Second cached entry"));
var nestedPageReferenceRun = root.Descendants(W.r)
.Single(run => run.Descendants(W.t).Any(text => text.Value == "2"));
var ordinaryRun = root.Descendants(W.r)
.Single(run => run.Descendants(W.t).Any(text => text.Value == "Ordinary link"));

var fieldResult = firstCachedRun.Annotation<Stack<FieldRetriever.FieldElementTypeInfo>>()!
.Single(info => info.FieldElementType == FieldRetriever.FieldElementTypeEnum.Result);
Assert.Equal("{ TOC \\o \"1-3\" \\h }", FieldRetriever.InstrText(root, fieldResult.Id));
Assert.True(FieldRetriever.IsFieldResult(firstCachedRun, "TOC"));
Assert.True(FieldRetriever.IsFieldResult(secondCachedRun, "TOC"));
Assert.True(FieldRetriever.IsFieldResult(nestedPageReferenceRun, "TOC"));
Assert.True(FieldRetriever.IsFieldResult(nestedPageReferenceRun, "PAGEREF"));
Assert.False(FieldRetriever.IsFieldResult(ordinaryRun, "TOC"));
}
}
29 changes: 29 additions & 0 deletions Docxodus/FieldRetriever.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,35 @@ public static string InstrText(XElement root, int id)
return "{" + instrText + "}";
}

/// <summary>
/// Returns whether <paramref name="element"/> is in the cached result of a complex field
/// of <paramref name="fieldType"/>. Nested fields are considered independently, so a
/// PAGEREF result nested inside a TOC result still reports both contexts correctly.
/// </summary>
internal static bool IsFieldResult(XElement element, string fieldType)
{
if (element == null || string.IsNullOrWhiteSpace(fieldType))
return false;

var stack = element.Annotation<Stack<FieldElementTypeInfo>>();
var root = element.AncestorsAndSelf().LastOrDefault();
if (stack == null || root == null)
return false;

return stack
.Where(info => info.FieldElementType == FieldElementTypeEnum.Result)
.Select(info => InstrText(root, info.Id).TrimStart('{').TrimEnd('}'))
.Select(instruction => instruction
.Split(
new[] { ' ', '\t', '\r', '\n' },
StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault())
.Any(type => string.Equals(
type,
fieldType,
StringComparison.OrdinalIgnoreCase));
}

public static void AnnotateWithFieldInfo(OpenXmlPart part)
{
XNamespace w = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
Expand Down
31 changes: 31 additions & 0 deletions Docxodus/WmlToHtmlConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5977,6 +5977,7 @@ private static object ConvertRun(WordprocessingDocument wordDoc, WmlToHtmlConver
return null;

var style = DefineRunStyle(run);
ApplyFieldResultPresentation(run, style);
if (paragraphHasExactLineHeight)
{
// CSS line boxes are allowed to grow when baseline-aligned runs use font
Expand Down Expand Up @@ -6207,6 +6208,36 @@ private static object ConvertRun(WordprocessingDocument wordDoc, WmlToHtmlConver
return content;
}

/// <summary>
/// Applies presentation semantics that belong to a cached field result rather than to an
/// individual run. Word suppresses hyperlink color and underline inside cached TOC
/// results, even when those runs reference the Hyperlink character style. The same run
/// outside the TOC field keeps its declared character style.
/// </summary>
private static void ApplyFieldResultPresentation(
XElement run,
Dictionary<string, string> style)
{
if (!run.Ancestors(W.hyperlink).Any() || !FieldRetriever.IsFieldResult(run, "TOC"))
return;

// Removing these properties exposes the underlying TOC paragraph/run formatting; it
// does not force black onto a document whose TOC style intentionally uses another
// color. Preserve any non-underline decoration that may coexist on the run.
style.Remove("color");
if (!style.TryGetValue("text-decoration", out var decoration))
return;

var remaining = decoration
.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
.Where(value => !string.Equals(value, "underline", StringComparison.OrdinalIgnoreCase))
.ToArray();
if (remaining.Length == 0)
style.Remove("text-decoration");
else
style["text-decoration"] = string.Join(" ", remaining);
}

private static string DescribeFormatChange(XElement currentRPr, XElement rPrChange)
{
var changes = new List<string>();
Expand Down
6 changes: 5 additions & 1 deletion docs/architecture/docx_converter.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,11 @@ When adjacent cells have different borders, priority is determined by:
```

The anchor suppresses browser-default link presentation. Explicit Word run styling inside the link
still wins, while unstyled cached field runs inherit the surrounding document color.
still wins in ordinary links. Cached `TOC` field results are the semantic exception: Word suppresses
hyperlink color and underline there even when a run references the `Hyperlink` character style.
`FieldRetriever` annotates the complex-field stack before transformation, and run conversion removes
only those two presentation properties when the run is both inside `w:hyperlink` and inside a `TOC`
result. Removing rather than replacing them exposes the document's underlying TOC formatting.

**HYPERLINK fields** (detected via `FieldRetriever`):
```html
Expand Down
66 changes: 36 additions & 30 deletions docs/ooxml_corner_cases.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ This document tracks edge cases and quirks in Open XML document processing where
4. [Paragraph Layout](#paragraph-layout)
- [`w:lineRule="auto"` is a multiple of the FONT's line box, not of font-size](#wlineruleauto-is-a-multiple-of-the-fonts-line-box-not-of-font-size)
- [An accumulated line-spacing error can resemble a top-margin deviation](#an-accumulated-line-spacing-error-can-resemble-a-top-margin-deviation)
- [LibreOffice ignores the `Hyperlink` character style on TOC field results](#libreoffice-ignores-the-hyperlink-character-style-on-toc-field-results)
- [Cached TOC field results suppress hyperlink presentation](#cached-toc-field-results-suppress-hyperlink-presentation)
5. [Theme Colors](#theme-colors)
- [`w:color`/`w:fill` are a CACHE; `w:themeColor`/`w:themeFill` are the authority](#wcolorwfill-are-a-cache-wthemecolorwthemefill-are-the-authority)
6. [Contributing](#contributing)
Expand Down Expand Up @@ -1342,22 +1342,26 @@ residual (substituted-font rasterization), not a `reference-deviation`.
cite Word evidence unless the corresponding measurement is committed.
- `npm/tests/visual-parity/ratchet.json` records the current Docxodus/LibreOffice F1 of 1.00000.

### LibreOffice ignores the `Hyperlink` character style on TOC field results
### Cached TOC field results suppress hyperlink presentation

#### Symptom

A generated table of contents renders blue and underlined in Docxodus (and in Word) but plain black
in LibreOffice, making a pixel comparison of any TOC document look like a Docxodus colour bug.
A cached table of contents rendered blue and underlined in Docxodus while both Word and
LibreOffice rendered its entries black. An ordinary hyperlink using the same paragraph and
character styles remained blue and underlined in Word.

#### Minimal XML reproducer

```xml
<w:hyperlink w:anchor="_Toc425251205" w:history="1">
<w:r>
<w:rPr><w:rStyle w:val="Hyperlink"/><w:noProof/></w:rPr>
<w:t>The first heading</w:t>
</w:r>
<w:r><w:fldChar w:fldCharType="begin"/></w:r>
<w:r><w:instrText> TOC \o "1-3" \h </w:instrText></w:r>
<w:r><w:fldChar w:fldCharType="separate"/></w:r>
<w:hyperlink w:anchor="_Toc425251205">
<w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr>
<w:t>The first heading</w:t></w:r>
</w:hyperlink>
<!-- cached entries may span paragraphs -->
<w:r><w:fldChar w:fldCharType="end"/></w:r>
```

with, in `styles.xml`:
Expand All @@ -1371,45 +1375,47 @@ with, in `styles.xml`:

#### The corner case

`w:hyperlink` is a *link*, not a style — nothing about it implies an appearance. The appearance
comes from the run's explicit `w:rStyle w:val="Hyperlink"` reference, and the referenced character
style declares the colour and the underline. Word writes exactly this when it builds a TOC with the
`\h` switch, which is why "my table of contents came out blue and underlined" is such a common Word
question.
`w:hyperlink` alone does not imply an appearance. Here the run explicitly references the
`Hyperlink` character style, which declares blue and underline, but Word applies a higher-level
presentation rule to hyperlinks in the cached result of a complex `TOC` field. That field context,
not the `TOC1` paragraph style or the anchor name, suppresses those two properties.

Measured over the TOC entry rows of `HC022-Table-Of-Contents.docx` at 96 DPI:
The Word-reference capture of `HC022-Table-Of-Contents.docx` was rasterized at 96 DPI. In the TOC
entry region `(90,135)–(730,220)`, Word contains **0 blue pixels**, while blue content elsewhere on
the same page rules out a global color or export artifact.

| Renderer | Dominant entry-text colour |
| Context | Word presentation |
|---|---|
| Word | `Hyperlink` style applied (blue, underlined) |
| Docxodus | `#0563C1` — the declared value, byte for byte |
| LibreOffice | `#000000` — the character style is dropped |
| Hyperlink inside the cached `TOC` result | Underlying TOC color; no underline |
| Ordinary hyperlink with the same `TOC1` + `Hyperlink` styles | `#0563C1`; underlined |

#### Analysis

LibreOffice's writer import appears to treat a TOC field result as generated content it re-styles
itself, discarding the character style the run references. The direction of the deviation is
decisive: Docxodus emits the value the file declares, so no renderer change is warranted. The
visual-parity corpus records `fields-and-tabs` as `reference-deviation` on this evidence.
The renderer already annotates every OOXML element with its enclosing complex-field stack before
HTML transformation. `FieldRetriever` now recognizes `TOC` and exposes whether a run belongs to its
cached result. Run styling then removes `color` and only the `underline` decoration for a
`w:hyperlink` in that context. Removing the properties instead of forcing black preserves an
intentional color supplied by the underlying TOC paragraph/run formatting.

#### Relevant code

- `Docxodus/WmlToHtmlConverter.cs` — character-style resolution emits `span.docx-Hyperlink` with
the declared `color`/`text-decoration`.
- `Docxodus/FieldRetriever.cs` — parses `TOC` and identifies its cached result across paragraphs.
- `Docxodus/WmlToHtmlConverter.cs` — applies cached-field presentation after normal run-style
resolution.

#### Tests

- `npm/tests/toc-line-geometry.spec.ts` — asserts a `w:rStyle`-carrying entry gets the declared
colour and underline, AND that an otherwise identical entry *without* `w:rStyle` gets neither.
The second half is what proves the renderer reads the style rather than decorating every
hyperlink; without it, "we match Word" would be indistinguishable from a lucky default.
- `Docxodus.Tests/FieldRetrieverTests.cs` — pins cross-paragraph result scope and proves it ends at
`w:fldCharType="end"`.
- `npm/tests/toc-line-geometry.spec.ts` — uses an actual cached `TOC` field plus an ordinary
same-style hyperlink control; it pins both presentation contexts and unchanged line geometry.

#### A trap when reducing this to a generated document

A programmatically built package needs a **`DocumentSettingsPart`** for character-style resolution
to run at all. Without `word/settings.xml`, the converter still emits the style's CSS *class* on the
run but generates an **empty rule** for it, so `w:rStyle` silently loses every declared property and
the reduced case appears to reproduce the LibreOffice behaviour. This is the same requirement
the reduced case appears to pass without exercising suppression. This is the same requirement
CLAUDE.md notes for programmatic .NET test documents.

## Theme Colors
Expand Down
Loading
Loading