diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2d53a36e..8911fe6a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
@@ -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
- `` 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,
diff --git a/Docxodus.Tests/FieldRetrieverTests.cs b/Docxodus.Tests/FieldRetrieverTests.cs
new file mode 100644
index 00000000..df7a0dfb
--- /dev/null
+++ b/Docxodus.Tests/FieldRetrieverTests.cs
@@ -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>()!
+ .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"));
+ }
+}
diff --git a/Docxodus/FieldRetriever.cs b/Docxodus/FieldRetriever.cs
index 14a09ce2..8b615801 100644
--- a/Docxodus/FieldRetriever.cs
+++ b/Docxodus/FieldRetriever.cs
@@ -80,6 +80,35 @@ public static string InstrText(XElement root, int id)
return "{" + instrText + "}";
}
+ ///
+ /// Returns whether is in the cached result of a complex field
+ /// of . Nested fields are considered independently, so a
+ /// PAGEREF result nested inside a TOC result still reports both contexts correctly.
+ ///
+ internal static bool IsFieldResult(XElement element, string fieldType)
+ {
+ if (element == null || string.IsNullOrWhiteSpace(fieldType))
+ return false;
+
+ var stack = element.Annotation>();
+ 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";
diff --git a/Docxodus/WmlToHtmlConverter.cs b/Docxodus/WmlToHtmlConverter.cs
index 861b02c6..41216b59 100644
--- a/Docxodus/WmlToHtmlConverter.cs
+++ b/Docxodus/WmlToHtmlConverter.cs
@@ -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
@@ -6207,6 +6208,36 @@ private static object ConvertRun(WordprocessingDocument wordDoc, WmlToHtmlConver
return content;
}
+ ///
+ /// 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.
+ ///
+ private static void ApplyFieldResultPresentation(
+ XElement run,
+ Dictionary 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();
diff --git a/docs/architecture/docx_converter.md b/docs/architecture/docx_converter.md
index b5a59aae..cd144e6b 100644
--- a/docs/architecture/docx_converter.md
+++ b/docs/architecture/docx_converter.md
@@ -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
diff --git a/docs/ooxml_corner_cases.md b/docs/ooxml_corner_cases.md
index bf30653a..7aae8174 100644
--- a/docs/ooxml_corner_cases.md
+++ b/docs/ooxml_corner_cases.md
@@ -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)
@@ -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
-
-
-
- The first heading
-
+
+ TOC \o "1-3" \h
+
+
+
+ The first heading
+
+
```
with, in `styles.xml`:
@@ -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
diff --git a/npm/tests/docx-toc-fixture.ts b/npm/tests/docx-toc-fixture.ts
index 1cf3a538..3a0a25f9 100644
--- a/npm/tests/docx-toc-fixture.ts
+++ b/npm/tests/docx-toc-fixture.ts
@@ -1,15 +1,14 @@
import { storedZip, xml } from './docx-zip.js';
/**
- * A generated table of contents (issue #397) — heading, dotted-leader entries, right-aligned page
- * numbers, and hyperlink runs — reduced to the two things the case is about: how tall a TOC entry's
- * line box is, and where its hyperlink appearance comes from.
+ * A generated cached table of contents (issues #397/#427) — an actual complex TOC field around
+ * dotted-leader entries and an ordinary hyperlink control after the field result. Both kinds of
+ * link use the same character style, so field context is the only styling variable.
*
* The interesting property is that the entry text carries `w:rStyle w:val="Hyperlink"`, and the
- * `Hyperlink` character style declares a color and an underline. That is the ONLY source of
- * hyperlink appearance in the file: `w:hyperlink` is a link, not a style. A renderer that paints a
- * hyperlink blue without being told to is fabricating, and one that ignores the declared style is
- * dropping — the fixture can tell the two apart because it emits both kinds of entry.
+ * `Hyperlink` character style declares a color and an underline. Word suppresses that presentation
+ * for hyperlinks inside the cached TOC result, but applies it to the otherwise-identical ordinary
+ * link. A renderer must therefore use field semantics, not paragraph-style or anchor heuristics.
*/
const w = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
@@ -31,30 +30,38 @@ export const TOC_TAB_TWIPS = 9350;
export interface TocEntry {
text: string;
page: string;
- /** Apply the `Hyperlink` character style to the entry text, as Word's `\h` TOC does. */
- styled: boolean;
}
export const TOC_ENTRIES: TocEntry[] = [
- { text: 'The first heading of the generated document', page: '1', styled: true },
- { text: 'The second heading of the generated document', page: '2', styled: true },
- { text: 'The third heading of the generated document', page: '3', styled: true },
- // The control: identical markup MINUS w:rStyle. Nothing in the file asks for hyperlink
- // appearance here, so anything blue or underlined would be the renderer's invention.
- { text: 'An entry whose run carries no character style', page: '4', styled: false },
+ { text: 'The first heading of the generated document', page: '1' },
+ { text: 'The second heading of the generated document', page: '2' },
+ { text: 'The third heading of the generated document', page: '3' },
];
+export const ORDINARY_HYPERLINK_TEXT = 'An ordinary hyperlink using the same character style';
function entryXml(entry: TocEntry, index: number): string {
- const rStyle = entry.styled ? '' : '';
+ const fieldStart = index === 0
+ ? '' +
+ ' TOC \\o "1-3" \\h \\z \\u ' +
+ ''
+ : '';
+ const fieldEnd = index === TOC_ENTRIES.length - 1
+ ? ''
+ : '';
// `w:webHidden` on the leader/page-number runs is what Word writes; they stay visible in print.
return `` +
`` +
- `` +
+ `${fieldStart}` +
`` +
- `${rStyle}${entry.text}` +
+ `` +
+ `${entry.text}` +
`` +
+ `` +
+ ` PAGEREF _Toc${index} \\h ` +
+ `` +
`${entry.page}` +
- ``;
+ `` +
+ `${fieldEnd}`;
}
export function generateTocDocx(): Uint8Array {
@@ -65,6 +72,13 @@ export function generateTocDocx(): Uint8Array {
Contents
${body}
+
+
+
+ ${ORDINARY_HYPERLINK_TEXT}
+
+
+
(p.textContent || '').includes('heading of the generated document') ||
- (p.textContent || '').includes('no character style'))
+ .filter(p => (p.textContent || '').includes('heading of the generated document'))
.map(p => {
const rect = p.getBoundingClientRect();
// The entry's own text run — NOT the leader/page-number runs, whose appearance is a
@@ -77,8 +74,7 @@ async function renderToc(page: import('@playwright/test').Page) {
// document order, so the LAST span carrying the entry text is the innermost one: the run
// that actually holds the character style.
const carriers = Array.from(p.querySelectorAll('span'))
- .filter(span => (span.textContent || '').includes('generated document') ||
- (span.textContent || '').includes('no character style'));
+ .filter(span => (span.textContent || '').includes('generated document'));
const run = carriers[carriers.length - 1] as HTMLElement;
const runStyle = getComputedStyle(run);
return {
@@ -113,7 +109,22 @@ async function renderToc(page: import('@playwright/test').Page) {
const nativeLineBox = probe.getBoundingClientRect().height;
probe.remove();
- return { entries: entries as EntryGeometry[], nativeLineBox };
+ const ordinaryParagraph = paragraphs.find(p =>
+ (p.textContent || '').includes('ordinary hyperlink')) as HTMLElement;
+ const ordinaryCarriers = Array.from(ordinaryParagraph.querySelectorAll('span'))
+ .filter(span => (span.textContent || '').includes('ordinary hyperlink'));
+ const ordinaryRun = ordinaryCarriers[ordinaryCarriers.length - 1] as HTMLElement;
+ const ordinaryStyle = getComputedStyle(ordinaryRun);
+
+ return {
+ entries: entries as EntryGeometry[],
+ nativeLineBox,
+ ordinary: {
+ text: ordinaryParagraph.textContent || '',
+ color: ordinaryStyle.color,
+ textDecorationLine: ordinaryStyle.textDecorationLine,
+ },
+ };
}, bytes);
}
@@ -151,45 +162,28 @@ test.describe('TOC entry line geometry', () => {
});
test.describe('TOC hyperlink appearance', () => {
- /**
- * The reference deviation, pinned as a positive statement about our own output: the declared
- * character style is applied. LibreOffice renders these entries black — see BASELINE.md — but it
- * is a comparison implementation, not the correctness oracle, and the file says otherwise.
- */
- test('an entry run styled `Hyperlink` gets the character style\'s declared color and underline',
+ test('cached TOC result links suppress the Hyperlink character style presentation',
async ({ page }) => {
const { entries } = await renderToc(page);
- const styled = entries.filter((_, index) => TOC_ENTRIES[index].styled);
- expect(styled.length).toBeGreaterThan(0);
-
- for (const entry of styled) {
- expect(entry.color, `${entry.text}: color`).toBe(TOC_HYPERLINK_RGB);
- expect(entry.textDecorationLine, `${entry.text}: decoration`).toContain('underline');
+ for (const entry of entries) {
+ expect(entry.color, `${entry.text}: color`).toBe('rgb(0, 0, 0)');
+ expect(entry.textDecorationLine, `${entry.text}: decoration`).not.toContain('underline');
}
});
- /**
- * The control that makes the assertion above meaningful. `w:hyperlink` is a link, not a style: a
- * renderer that decorated every hyperlink would pass the previous test while actually ignoring
- * the style, and LibreOffice would then be the one following the file.
- */
- test('an identical entry WITHOUT the character style is not decorated', async ({ page }) => {
- const { entries } = await renderToc(page);
- const unstyledIndex = TOC_ENTRIES.findIndex(entry => !entry.styled);
- expect(unstyledIndex).toBeGreaterThanOrEqual(0);
- const unstyled = entries[unstyledIndex];
-
- expect(unstyled.color, 'an unstyled hyperlink run must not be painted hyperlink blue')
- .not.toBe(TOC_HYPERLINK_RGB);
- expect(unstyled.textDecorationLine,
- 'an unstyled hyperlink run must not be underlined by the renderer')
- .not.toContain('underline');
- });
+ test('an ordinary link using the same paragraph and character styles remains decorated',
+ async ({ page }) => {
+ const { ordinary } = await renderToc(page);
+ expect(ordinary.text).toBe(ORDINARY_HYPERLINK_TEXT);
+ expect(ordinary.color).toBe(TOC_HYPERLINK_RGB);
+ expect(ordinary.textDecorationLine).toContain('underline');
+ });
test('hyperlink appearance is independent of line geometry', async ({ page }) => {
const { entries } = await renderToc(page);
const boxes = new Set(entries.map(entry => Math.round(entry.lineBox * 100)));
- // Styled and unstyled entries share one line box: colour and underline must not change height.
+ // Field-suppressed and ordinary presentation share one line box: appearance does not change
+ // geometry.
expect(boxes.size, 'the character style must not alter the line box').toBe(1);
});
});
diff --git a/npm/tests/visual-parity/BASELINE.md b/npm/tests/visual-parity/BASELINE.md
index 8cd883fc..a3c66eb1 100644
--- a/npm/tests/visual-parity/BASELINE.md
+++ b/npm/tests/visual-parity/BASELINE.md
@@ -276,7 +276,8 @@ Two dimensions the error was directly visible in:
- **`fields-and-tabs` (issue #397's line-box half).** TOC entries were displaced by a growing
amount down the page — 7.3px, 11.0px, 14.7px for the first three. They now land at 140.05,
166.13, 192.20 against LibreOffice's 140.17, 166.17, 192.17: **within 0.12px**. Ink F1 goes from
- 0.15913 to 0.99775. The remaining difference is hyperlink styling, which issue #397 attributes.
+ 0.15913 to 0.99775. At this point hyperlink styling remained; the Word capture in issue #427
+ later established it as a renderer bug and the dedicated section below records the fix.
The corpus rerun also dissolved a standing attribution. `numbered-lists` was `reference-deviation`
on the reading that "the whole content is about 28px lower, so LibreOffice must import the 1701-twip
@@ -319,43 +320,25 @@ No severe case remains and the strict-gating set is empty, but strict mode is NO
that is a separate decision, and two `major` cases still sit above the threshold a strict run
would eventually want.
-## TOC hyperlink styling — 2026-08-11 (issue #397)
+## TOC field-result presentation — 2026-08-13 (issue #427)
-Issue #397 named two residuals in the `fields-and-tabs` case, and they turned out to have opposite
-answers. Pinning them separately is what the issue asked for, and it is what the evidence supports.
+Issue #397 correctly fixed TOC line geometry but inferred hyperlink presentation from OOXML alone.
+The Word-reference capture resolves the disputed behavior: in the measured TOC entry region
+`(90,135)–(730,220)`, Word contains **0 blue pixels**, while blue content elsewhere on the same page
+proves the export did not globally discard color. Cached `TOC` field-result links are black and not
+underlined in Word despite their `Hyperlink` character style.
-**Line-box height was ours.** TOC entries drifted further down the page with every entry — 7.3px,
-11.0px, 14.7px for the first three — because automatic line spacing was measured against font-size
-instead of the font's own line box. Fixed in issue #396 above; entries now land within **0.12px**
-of LibreOffice and the case's ink F1 is 0.99775.
+The fix follows the renderer's existing field architecture. `FieldRetriever` now recognizes `TOC`
+and reports cached-result membership from the annotated complex-field stack. After normal run-style
+resolution, the HTML converter removes hyperlink color and underline only when a run is both inside
+`w:hyperlink` and inside that field result. It does not force black, so underlying TOC formatting is
+still authoritative. An ordinary link using the same `TOC1` paragraph style and `Hyperlink`
+character style remains blue and underlined.
-**Hyperlink styling is not.** The entry run in `HC022` carries ``, and
-the document's `Hyperlink` character style declares `w:color="0563C1"` with `w:u w:val="single"`.
-Sampling the two renders over the TOC entry rows:
-
-| Renderer | Dominant entry-text colour |
-|---|---|
-| Docxodus | **`#0563C1`** — the declared value, byte for byte |
-| LibreOffice | **`#000000`** — no hyperlink colour at all |
-
-Docxodus renders what the file declares. LibreOffice drops a character style that the run
-explicitly references, which is a deviation from the OOXML, not a Docxodus defect — and "the TOC
-came out blue and underlined" is the well-known consequence of Word's own `\h` table of contents
-applying this style. LibreOffice is a comparison implementation, not the correctness oracle, so the
-output is **not** changed to match it and the disposition moves `renderer-bug` →
-`reference-deviation`. This is the same standard applied when the `numbered-lists` margin was
-kept over LibreOffice's import.
-
-The claim only means something if the renderer is reading the style rather than decorating every
-hyperlink it sees, so `npm/tests/toc-line-geometry.spec.ts` generates a TOC containing both kinds
-of entry: one with `w:rStyle` and an otherwise identical one without. The styled entry must carry
-the declared colour and underline; the unstyled one must carry neither. Line geometry is pinned by
-separate assertions — the entry line box equals the OOXML multiple of the font's line box, and the
-entries are evenly spaced so displacement cannot accumulate — and one assertion pins that the two
-are independent, i.e. that applying the character style does not change the line box.
-
-With this, the corpus's remaining non-`environment` work is the `merged-table` colour delta (issue
-#399), the last `unattributed` case.
+The filtered `fields-and-tabs` rerun (LibreOffice 25.8.7.3, Chromium 143.0.7499.4, Poppler 25.03.0)
+remains **minor**, with SSIM **0.95942** and tolerant ink F1 **0.99868**. The tracked Word region now
+has **0 `#0563C1` or blue-dominant pixels** in Docxodus as well. Its disposition moves from
+`reference-deviation` to `environment`; the remaining delta is same-font rasterization/line metrics.
## The merged-table colour delta — 2026-08-11 (issue #399)
@@ -627,7 +610,7 @@ all 21 together.
| inline-image | 1/1 | major | environment | 0.93255 | 0.77340 | Improved by issue #396 (ink F1 0.64760 to 0.77340). Residual is indentation/wrapping of the same fonts, not an inline-flow failure. |
| chart | 1/1 | close | environment | 0.98687 | 0.96817 | Cached clustered column data renders as accessible inline SVG at the stored extent. Other chart families remain unsupported. |
| shape | 1/1 | close | renderer-bug | 0.98599 | 1.00000 | Auto-fit height now follows the laid-out text (issue #396): height error -16.0 px to +2.7 px, ink geometry exact. Residual is the CSS border adding to an auto height where DrawingML strokes `a:ln` on the shape boundary. |
-| fields-and-tabs | 1/1 | minor | reference-deviation | 0.96026 | 0.99775 | Tab targets and leaders correct since PR #380; entry line height correct since issue #396 (within 0.12 px). The residual is hyperlink styling: the run references the `Hyperlink` character style, Docxodus paints its declared `#0563C1`, LibreOffice paints black (issue #397). |
+| fields-and-tabs | 1/1 | minor | environment | 0.95942 | 0.99868 | Word evidence shows 0 blue pixels in the cached TOC result; issue #427 now suppresses hyperlink presentation from semantic field context. Remaining delta is rasterization/line metrics. |
| footnote | 1/1 | close | environment | 0.99502 | 0.99064 | Note placement fixed (issue #378); issue #396 stopped the superscript reference inflating its line box. Residual is substituted-font rasterization and LibreOffice-24.2 separator width. |
| tracked-deletion | 1/1 | minor | environment | 0.97950 | 0.99667 | Identical accepted-revision bytes are compared. Improved by the font contract (issue #379) and issue #396; residual is same-font heading metrics and wrapping. |
diff --git a/npm/tests/visual-parity/WORD_REFERENCE.md b/npm/tests/visual-parity/WORD_REFERENCE.md
index fdca0205..423da93a 100644
--- a/npm/tests/visual-parity/WORD_REFERENCE.md
+++ b/npm/tests/visual-parity/WORD_REFERENCE.md
@@ -102,8 +102,9 @@ Maintained as dispositions change; see each case's `rationale` in `corpus.ts` fo
on the paragraph preceding a nested table? (Docxodus paints; LibreOffice suppresses.)
- `legal-contract` — does Word drop heading space-before at the top of a page? (LibreOffice
drops; Docxodus paints the declared value.)
-- `fields-and-tabs` — does Word paint the `Hyperlink` character style's declared color on TOC
- entries? (Docxodus paints `#0563C1`; LibreOffice paints black.)
+- `fields-and-tabs` — **resolved by issue #427:** Word records 0 blue pixels in the cached TOC
+ result, so field context suppresses hyperlink presentation; an ordinary same-style link remains
+ blue and underlined.
- `shape` — does the `a:ln` outline enlarge an auto-fit shape in Word? (DrawingML says no;
CSS borders do.)
- The #404 reductions (`landscape-section`, `inline-image`, `tracked-deletion`) — whether
diff --git a/npm/tests/visual-parity/corpus.ts b/npm/tests/visual-parity/corpus.ts
index 10eb4e67..a885e67b 100644
--- a/npm/tests/visual-parity/corpus.ts
+++ b/npm/tests/visual-parity/corpus.ts
@@ -216,15 +216,16 @@ export const VISUAL_PARITY_CORPUS: VisualCorpusEntry[] = [
categories: ['fields', 'text'],
rationale: 'Field results, tab leaders, and right-aligned page numbers.',
disposition: {
- kind: 'reference-deviation',
+ kind: 'environment',
rationale: 'Tab targets and leaders are correct since PR #380, and TOC entry line height is ' +
'correct since issue #396 — entries land within 0.12 px of LibreOffice, where they were ' +
- 'displaced by up to 14.7 px, and ink F1 is 0.99775. The residual is hyperlink styling, ' +
- 'attributed in issue #397: the entry runs carry `w:rStyle w:val="Hyperlink"`, and that ' +
- 'character style declares `w:color 0563C1` and `w:u single`. Docxodus paints exactly the ' +
- 'declared colour; LibreOffice paints the entries black, ignoring the style the file ' +
- 'applies. Docxodus follows the OOXML, so the output is not changed to match.',
- reference: 'https://github.com/JSv4/Docxodus/issues/397',
+ 'displaced by up to 14.7 px. Issue #427 established the remaining presentation rule from ' +
+ 'Word evidence: cached TOC field-result links suppress hyperlink colour and underline. ' +
+ 'Docxodus now applies that rule from the annotated field stack; the remaining minor ' +
+ 'pixel delta is same-font rasterization and line metrics.',
+ reference: 'https://github.com/JSv4/Docxodus/issues/427',
+ wordEvidence: 'The measured Word TOC region (x=90..730, y=135..220) contains 0 blue pixels; ' +
+ 'blue content elsewhere on the page proves this is scoped to the cached field result.',
},
},
{
diff --git a/npm/tests/visual-parity/ratchet.json b/npm/tests/visual-parity/ratchet.json
index a100275e..24e7e8e7 100644
--- a/npm/tests/visual-parity/ratchet.json
+++ b/npm/tests/visual-parity/ratchet.json
@@ -71,7 +71,7 @@
},
{
"id": "fields-and-tabs",
- "disposition": "reference-deviation",
+ "disposition": "environment",
"pages": {
"docxodus": 1,
"libreoffice": 1
diff --git a/npm/tests/visual-parity/word-reference.json b/npm/tests/visual-parity/word-reference.json
index e9af3a8c..9dafe766 100644
--- a/npm/tests/visual-parity/word-reference.json
+++ b/npm/tests/visual-parity/word-reference.json
@@ -12,7 +12,37 @@
{ "id": "chart-pie", "status": "pending" },
{ "id": "chart-stacked", "status": "pending" },
{ "id": "endnote", "status": "pending" },
- { "id": "fields-and-tabs", "status": "pending" },
+ {
+ "id": "fields-and-tabs",
+ "status": "measured",
+ "fixtureSha256": "254cded895a2987f5e0b88917f6cfd710a38b447d00ffb090e6496df54d009e6",
+ "pageCount": 1,
+ "pages": [
+ {
+ "page": 1,
+ "width": 816,
+ "height": 1056,
+ "inkBounds": {
+ "left": 96,
+ "top": 118,
+ "right": 718,
+ "bottom": 409
+ },
+ "inkPixelRatio": 0.01476
+ }
+ ],
+ "keyMeasurements": {
+ "tocEntryRegionLeftPx": 90,
+ "tocEntryRegionTopPx": 135,
+ "tocEntryRegionRightPx": 730,
+ "tocEntryRegionBottomPx": 220,
+ "tocEntryBluePixels": 0,
+ "tocEntryInkTopPx": 144,
+ "tocEntryInkBottomPx": 208
+ },
+ "notes": "Word renders the cached TOC field-result entries black in the measured region while retaining blue content elsewhere on the page.",
+ "capturedAt": "2026-08-13"
+ },
{ "id": "footnote", "status": "pending" },
{ "id": "inline-image", "status": "pending" },
{ "id": "landscape-section", "status": "pending" },