From ff8fad84761c4eec3f77dc3099e4b4eb0b96f916 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Thu, 13 Aug 2026 00:25:02 -0500 Subject: [PATCH] Render cached TOC links like Word --- CHANGELOG.md | 23 ++---- Docxodus.Tests/FieldRetrieverTests.cs | 72 ++++++++++++++++++ Docxodus/FieldRetriever.cs | 29 ++++++++ Docxodus/WmlToHtmlConverter.cs | 31 ++++++++ docs/architecture/docx_converter.md | 6 +- docs/ooxml_corner_cases.md | 66 +++++++++-------- npm/tests/docx-toc-fixture.ts | 52 ++++++++----- npm/tests/toc-line-geometry.spec.ts | 82 ++++++++++----------- npm/tests/visual-parity/BASELINE.md | 55 +++++--------- npm/tests/visual-parity/WORD_REFERENCE.md | 5 +- npm/tests/visual-parity/corpus.ts | 15 ++-- npm/tests/visual-parity/ratchet.json | 2 +- npm/tests/visual-parity/word-reference.json | 32 +++++++- 13 files changed, 313 insertions(+), 157 deletions(-) create mode 100644 Docxodus.Tests/FieldRetrieverTests.cs 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" },