Skip to content

Commit 4b7af9c

Browse files
phodalcodex
andcommitted
feat(office): render docx underline styles
Co-authored-by: Codex (GPT 5.5) <codex@openai.com>
1 parent 9df71e4 commit 4b7af9c

3 files changed

Lines changed: 30 additions & 10 deletions

File tree

docs/issues/2026-05-01-office-document-viewer-wasm-reader.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ Routa 目前仅有 `file-output-viewer`(代码/搜索结果)和 `reposlide`
5454
- [x] DOCX real-world list/style section quirks now match Walnut decoded JSON for `06.docx`: paragraph numbering writes `autoNumberType`/`autoNumberStartAt` from numbering definitions and `w:startOverride`, paragraph styles with only alignment inherit default run font size, and empty `w:sectPr` no longer materializes synthetic page setup.
5555
- [x] DOCX real-world table/style text quirks now match Walnut decoded JSON for `/Users/phodal/Downloads/realworld/5d1b8b8662d700110424b9ccc08ed7a1.docx`: direct table cell margins are emitted in EMU, shallow paragraph `w:firstLine` indent is preserved without over-materializing `w:hanging`, highlight values enter `textStyle.scheme`, empty/invalid table borders are suppressed, and paragraph style summaries respect `w:jc="both"` plus Walnut's default run-font inheritance boundaries.
5656
- [x] DOCX non-Word producer decimal sizing now follows Walnut's protocol materialization for comparable files: decimal `w:ind` and decimal `w:szCs` values are not rounded into paragraph/style protocol fields, while integer default complex-script font-size inheritance is still retained for alignment-only styles without direct run fonts. This moved `/Users/phodal/Downloads/realworld/CI_CD.docx` from 98 decoded JSON diffs to `0` and `/Users/phodal/Downloads/realworld/ChocolateFactory.docx` from 1 decoded JSON diff to `0`.
57-
- [x] DOCX debug renderer now consumes decoded section page setup, header/footer content, column settings including separator lines, paragraph alignment, margin/hanging indent, line spacing, list auto-number and bullet markers, run hyperlinks, Word underline values, explicit false run emphasis overrides, DOCX highlight/caps/typeface scheme metadata, footnote/comment reference markers and bodies, insertion review marks, table bbox/widths/spans/margins/borders, image/table/chart bbox offsets and sizing, and chart references.
57+
- [x] DOCX debug renderer now consumes decoded section page setup, header/footer content, column settings including separator lines, paragraph alignment, margin/hanging indent, line spacing, list auto-number and bullet markers, run hyperlinks, Word underline values/styles, explicit false run emphasis overrides, DOCX highlight/caps/typeface scheme metadata, footnote/comment reference markers and bodies, insertion review marks, table bbox/widths/spans/margins/borders, image/table/chart bbox offsets and sizing, and chart references.
5858
- [ ] DOCX visual layout depth: core protocol parity is green for the committed contract fixtures and targeted real-world Chinese samples. Remaining visual-fidelity work is floating wrap mode, overlap/behind-doc flags, z-order, distance-from-text, crop/effect metadata, and broader anchored-position variants beyond the common page/margin/column/paragraph align/offset cases already covered.
5959
- [ ] DOCX chart payload depth: chart references and basic cached series now flow into the debug renderer; remaining work is richer Word-specific axis/title/legend/plot-area styling, multi-axis charts, and embedded chart workbook/cache edge cases.
6060
- [ ] DOCX section/header/footer long-tail: section summaries, `sections[].elements`, and leading-vs-mid-paragraph rendered page-break assignment now match the tracked-change-heavy Chinese samples. Remaining work is broader column/header/footer combinations and more non-Word producer edge cases.
@@ -645,7 +645,7 @@ Verification on 2026-05-04:
645645
- `scripts/office-wasm-reader/scan-docx-protocol-corpus.ts` now separates Walnut failures from Routa failures. When the Walnut comparator fails, the scanner runs a Routa-only smoke extraction and classifies the result as `walnut-error-routa-ok` or `routa-error`. The scanner also supports `--compact` for corpus triage and `--timeout-ms=N` for opt-in bounded Walnut comparisons.
646646
- Full local corpus scan on `/Users/phodal/Downloads/realworld` with decoded JSON contract coverage found `166/166` Routa-readable DOCX files. Among them, `89/89` Walnut-readable files reported `0` normalized decoded Proto JSON diffs; the remaining `77` files were Walnut failures but Routa-only successes. The final scan had `mismatchCount = 0`, `errorCount = 0`, `okCount = 89`, and `walnutErrorRoutaOkCount = 77`.
647647
- Representative real-world JSON exact checks still pass for `/Users/phodal/Downloads/realworld/ThoughtWorks_黄峰达.docx`, `/Users/phodal/Downloads/realworld/目录v3.docx`, and the committed DOCX contract fixtures. `/Users/phodal/Downloads/realworld/About Thoughtworks.docx` is now correctly classified as `walnut-error-routa-ok`: Walnut throws `Format_InvalidStringWithValue, 100.0`, while Routa emits a smoke-valid `oaiproto.coworker.docx.Document` with resolved image references.
648-
- `npx vitest run src/app/debug/office-wasm-poc/__tests__/word-preview.test.tsx` passed after adding DOCX renderer coverage for section page setup, header/footer content and columns, paragraph alignment/indent/line spacing/list/bullet markers, hyperlinks, Word underline values, explicit false run emphasis overrides, DOCX highlight/caps/typeface scheme metadata, footnote/comment reference markers and bodies, insertion review marks, table bbox/spans/widths/margins/anchors/borders, image/table/chart bbox offsets and sizing, and chart-reference canvas rendering.
648+
- `npx vitest run src/app/debug/office-wasm-poc/__tests__/word-preview.test.tsx` passed after adding DOCX renderer coverage for section page setup, header/footer content and columns, paragraph alignment/indent/line spacing/list/bullet markers, hyperlinks, Word underline values/styles, explicit false run emphasis overrides, DOCX highlight/caps/typeface scheme metadata, footnote/comment reference markers and bodies, insertion review marks, table bbox/spans/widths/margins/anchors/borders, image/table/chart bbox offsets and sizing, and chart-reference canvas rendering.
649649

650650
Verification on 2026-05-02:
651651

src/app/debug/office-wasm-poc/__tests__/word-preview.test.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,10 @@ describe("WordPreview", () => {
226226
{
227227
runs: [
228228
{ text: "Underlined", textStyle: { underline: "single" } },
229+
{ text: "Double", textStyle: { underline: "double" } },
230+
{ text: "Dotted", textStyle: { underline: "dottedHeavy" } },
231+
{ text: "Dashed", textStyle: { underline: "dash" } },
232+
{ text: "Wavy", textStyle: { underline: "wave" } },
229233
{ text: "Not underlined", textStyle: { underline: "none" } },
230234
],
231235
},
@@ -238,7 +242,12 @@ describe("WordPreview", () => {
238242

239243
const runs = Array.from(container.querySelectorAll<HTMLElement>("p span"));
240244
expect(runs[0]?.style.textDecoration).toBe("underline");
241-
expect(runs[1]?.style.textDecoration).toBe("none");
245+
expect(runs[1]?.style.textDecoration).toBe("underline");
246+
expect(runs[1]?.style.textDecorationStyle).toBe("double");
247+
expect(runs[2]?.style.textDecorationStyle).toBe("dotted");
248+
expect(runs[3]?.style.textDecorationStyle).toBe("dashed");
249+
expect(runs[4]?.style.textDecorationStyle).toBe("wavy");
250+
expect(runs[5]?.style.textDecoration).toBe("none");
242251
});
243252

244253
it("renders decoded DOCX auto-number markers in document order", () => {

src/app/debug/office-wasm-poc/office-preview-utils.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -350,19 +350,30 @@ export function textRunStyle(run: TextRunView, fontScale = 1): CSSProperties {
350350
fontSize: runFontSize == null ? undefined : Math.max(fontScale < 1 ? 2 : 8, Math.min(fontScale < 1 ? 12 : 72, runFontSize)),
351351
fontStyle: run.style?.italic === true ? "italic" : run.style?.italic === false ? "normal" : undefined,
352352
fontWeight: run.style?.bold === true ? 700 : run.style?.bold === false ? 400 : undefined,
353-
textDecoration: docxTextDecoration(run.style?.underline),
353+
...docxTextDecoration(run.style?.underline),
354354
textTransform: scheme.textTransform,
355355
};
356356
}
357357

358-
function docxTextDecoration(value: unknown): CSSProperties["textDecoration"] {
359-
if (value === true) return "underline";
360-
if (value === false) return "none";
358+
function docxTextDecoration(value: unknown): Pick<CSSProperties, "textDecoration" | "textDecorationStyle"> {
359+
if (value === true) return { textDecoration: "underline" };
360+
if (value === false) return { textDecoration: "none" };
361361

362362
const underline = asString(value).toLowerCase();
363-
if (!underline) return undefined;
364-
if (underline === "none") return "none";
365-
return "underline";
363+
if (!underline) return {};
364+
if (underline === "none") return { textDecoration: "none" };
365+
return {
366+
textDecoration: "underline",
367+
textDecorationStyle: docxUnderlineStyle(underline),
368+
};
369+
}
370+
371+
function docxUnderlineStyle(underline: string): CSSProperties["textDecorationStyle"] {
372+
if (underline.includes("double")) return "double";
373+
if (underline.includes("dotted") || underline.includes("dot")) return "dotted";
374+
if (underline.includes("dash")) return "dashed";
375+
if (underline.includes("wave") || underline.includes("wavy")) return "wavy";
376+
return undefined;
366377
}
367378

368379
function docxSchemeStyle(scheme: unknown): Pick<CSSProperties, "backgroundColor" | "textTransform"> & {

0 commit comments

Comments
 (0)