From 32ac7816e0eadf0e56ddd9c2edec4fd2be989c42 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 5 Oct 2026 07:48:43 +0100
Subject: [PATCH 1/4] fix(docx): elect Normal from text in runs and table
cells, each in the style it is written in
---
CHANGELOG.md | 13 ++
docs/recipes/docx-export.md | 4 +-
.../libreoffice-windows-lines.tsv | 131 +++++++++---------
.../docx-fidelity/libreoffice-windows.tsv | 2 +-
.../docx-fidelity/word-windows-lines.tsv | 2 +-
.../semantic/docx/DocxSemanticBackend.java | 76 ++++++++--
.../semantic/docx/DocxDocumentStyleTest.java | 34 +++++
.../semantic/docx/DocxParagraphMarkTest.java | 6 +-
8 files changed, 190 insertions(+), 78 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1d7053f23..1aca91777 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,19 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **Restyling Normal reaches a DOCX's body text when the body is in runs or tables.** Normal is
+ elected from the style the most characters are set in, and the count left two kinds of text
+ out. A paragraph made of runs counted in its paragraph's style, which its runs set aside, and
+ table cells did not count at all. Where most of the text sits in runs or cells — invoices,
+ proposals, a rota — Normal came from the few paragraphs left, and the body carried a direct
+ size that restyling Normal did not reach: Normal 14pt over 10pt text in `ProposalEditorial`,
+ 12pt over 10.5pt in `CobaltRota`. Each piece of text now counts in the style it is written
+ in, a table cell's in the style its cascade resolves to. Word's editing protocol finds the
+ body following Normal in 61 of the 62 corpus documents, against 54; `ProposalNorthline`
+ still elects 10pt over its 9pt body. LibreOffice sets `VioletGrid` on one page, as the PDF,
+ instead of two, its 37 lines past 2pt down to none; 65 lines move in all, 50 nearer, and the
+ 15 further are `CobaltRota`'s by 0.05–0.4pt across. In Word one line moves:
+ `CobaltRota`'s "quayside bar" heading, 0.36pt further across.
- **Enter in a DOCX continues the text it follows.** Word gives a new paragraph its mark's
formatting, and the export left the mark of most paragraphs — every one written at an exact
line height — in the document's own size and face. Pressing Enter at the end of a paragraph
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index eeeafb2db..97f381eb5 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -342,7 +342,9 @@ and a header's or footer's line keep the page's measure too.
## Named styles, so the document can be restyled
The export writes a styles part whose `Normal` carries the document's own body text —
-the style the most characters are set in, not the one the most nodes use. Runs that only
+the style the most characters are set in, not the one the most nodes use. Each piece of text
+counts in the style it is written in: a run in its own, a table cell's in the style its
+cascade resolves to. Runs that only
restate it stay silent, so changing `Normal` in Word changes the body the way a reader
expects. A run whose font, size or colour differs keeps saying so, so headings, chips and
accents are unaffected.
diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv
index 1d05dd45d..053e99799 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv
@@ -1663,45 +1663,46 @@ cv-violet_grid 0:53e5e7c5a884 -0.20 0.10 experience
cv-violet_grid 0:5a0774260e0c -0.08 0.10 2022-present
cv-violet_grid 0:204f460767ff -0.12 0.02 seniorux/uidesigner|novafin(fint
cv-violet_grid 0:318bba211bdc -0.07 -0.23 sanfrancisco,ca
-cv-violet_grid 0:318bba211bdc#1 8.85 -0.23 sanfrancisco,ca
+cv-violet_grid 0:318bba211bdc#1 -0.15 -0.23 sanfrancisco,ca
cv-violet_grid 0:3883e88c63c3 -0.08 0.05 ledend-to-enddesignforapersonalf
-cv-violet_grid 0:ccc00663f089 1.70 0.05 conducteduserresearch,builtperso
-cv-violet_grid 0:7d0f908c7e5f 3.48 0.05 designedwireframes,prototypes,an
-cv-violet_grid 0:9b57c4a85aad 5.27 0.05 establishedandmaintainedascalabl
-cv-violet_grid 0:6894eafb99c2 7.05 0.05 collaboratedwithproductmanagers,
-cv-violet_grid 0:01e4455cec51 8.84 0.10 2020-2022
-cv-violet_grid 0:24d722f0cfbe 8.80 0.02 uxdesigner|brighthealth(healthte
-cv-violet_grid 0:530707fb0897 8.84 0.05 redesignedpatientportalexperienc
-cv-violet_grid 0:b2e27a1d9270 10.62 0.05 plannedandfacilitatedusabilityte
-cv-violet_grid 0:ab80a52d70fe 12.40 0.05 partneredwithengineerstoensurefe
-cv-violet_grid 0:11cbf615189b 14.19 0.05 createdreusablecomponentsandstyl
-cv-violet_grid 0:7e5081a3ed48 15.98 0.10 2018-2020
-cv-violet_grid 0:73f7bbf7cd60 15.94 0.02 juniorux/uidesigner|lumendigital
-cv-violet_grid 0:c6b06045332f 15.99 -0.17 austin,tx
-cv-violet_grid 0:5ea8234925c5 15.98 0.05 supporteddiscoveryworkshops,user
-cv-violet_grid 0:46f00c76528c 17.76 0.05 designedresponsivewebsitesandlan
-cv-violet_grid 0:f3cea36a9d83 19.54 0.05 collaboratedwithcopywritersandde
-cv-violet_grid 0:d94eccbb1fbe 21.31 0.10 selectedprojects
-cv-violet_grid 0:45dc8bf81211 21.26 0.08 novafinmobileapp|personalfinance
-cv-violet_grid 0:d398b29d3dbb 21.31 0.61 2023
-cv-violet_grid 0:e66c1ce573d3 21.17 0.08 ledux/uidesignforbudgeting,goalt
-cv-violet_grid 0:0ed8829e68d0 21.17 0.08 thatinformedasimplifiednavigatio
-cv-violet_grid 0:ee9bd17ebccf 21.17 0.08 22%increaseinweeklyactiveusers.
-cv-violet_grid 0:28b4603d784a 21.25 0.08 brighthealthpatientportal|health
-cv-violet_grid 0:1bea20e1df19 21.30 0.61 2021
-cv-violet_grid 0:999ddb26bed6 21.16 0.08 redesignedkeypatientflowsincludi
-cv-violet_grid 0:073d15633c0b 21.16 0.08 systemandcomponentlibraryusedacr
-cv-violet_grid 0:68bf0109c6f8 21.41 0.10 education
-cv-violet_grid 0:6cb574fa10b6 21.41 0.07 languages
-cv-violet_grid 0:0cf7506cf94a 21.48 0.10 bacheloroffineartsingraphicdesig
-cv-violet_grid 0:00ab7a6aa5f1 21.49 0.10 californiacollegeofthearts
-cv-violet_grid 0:7e2c21bb41db 21.48 0.10 sanfrancisco,ca|2014-2018
-cv-violet_grid 0:b6234d2ea0d6 20.73 0.02 english
-cv-violet_grid 0:bef32d2c315a 37.47 -0.03 native
-cv-violet_grid 0:bef32d2c315a#1 5.21 -0.03 native
-cv-violet_grid 0:9c9b13446cd9 21.96 0.02 spanish
-cv-violet_grid 0:e35242c809c8 23.19 0.02 portuguese
-cv-violet_grid 0:c5d1de4964d6 23.19 -0.03 conversational
+cv-violet_grid 0:ccc00663f089 -0.10 0.05 conducteduserresearch,builtperso
+cv-violet_grid 0:7d0f908c7e5f -0.12 0.05 designedwireframes,prototypes,an
+cv-violet_grid 0:9b57c4a85aad -0.13 0.05 establishedandmaintainedascalabl
+cv-violet_grid 0:6894eafb99c2 -0.15 0.05 collaboratedwithproductmanagers,
+cv-violet_grid 0:01e4455cec51 -0.16 0.10 2020-2022
+cv-violet_grid 0:24d722f0cfbe -0.20 0.02 uxdesigner|brighthealth(healthte
+cv-violet_grid 0:530707fb0897 -0.16 0.05 redesignedpatientportalexperienc
+cv-violet_grid 0:b2e27a1d9270 -0.18 0.05 plannedandfacilitatedusabilityte
+cv-violet_grid 0:ab80a52d70fe -0.20 0.05 partneredwithengineerstoensurefe
+cv-violet_grid 0:11cbf615189b -0.21 0.05 createdreusablecomponentsandstyl
+cv-violet_grid 0:7e5081a3ed48 -0.22 0.10 2018-2020
+cv-violet_grid 0:73f7bbf7cd60 -0.26 0.02 juniorux/uidesigner|lumendigital
+cv-violet_grid 0:c6b06045332f -0.21 -0.17 austin,tx
+cv-violet_grid 0:5ea8234925c5 -0.22 0.05 supporteddiscoveryworkshops,user
+cv-violet_grid 0:46f00c76528c -0.24 0.05 designedresponsivewebsitesandlan
+cv-violet_grid 0:f3cea36a9d83 -0.26 0.05 collaboratedwithcopywritersandde
+cv-violet_grid 0:d94eccbb1fbe -0.29 0.10 selectedprojects
+cv-violet_grid 0:45dc8bf81211 -0.34 0.08 novafinmobileapp|personalfinance
+cv-violet_grid 0:d398b29d3dbb -0.29 0.61 2023
+cv-violet_grid 0:e66c1ce573d3 -0.43 0.08 ledux/uidesignforbudgeting,goalt
+cv-violet_grid 0:0ed8829e68d0 -0.43 0.08 thatinformedasimplifiednavigatio
+cv-violet_grid 0:ee9bd17ebccf -0.43 0.08 22%increaseinweeklyactiveusers.
+cv-violet_grid 0:28b4603d784a -0.35 0.08 brighthealthpatientportal|health
+cv-violet_grid 0:1bea20e1df19 -0.30 0.61 2021
+cv-violet_grid 0:999ddb26bed6 -0.44 0.08 redesignedkeypatientflowsincludi
+cv-violet_grid 0:073d15633c0b -0.44 0.08 systemandcomponentlibraryusedacr
+cv-violet_grid 0:68bf0109c6f8 -0.19 0.10 education
+cv-violet_grid 0:6cb574fa10b6 -0.19 0.07 languages
+cv-violet_grid 0:0cf7506cf94a -0.12 0.10 bacheloroffineartsingraphicdesig
+cv-violet_grid 0:00ab7a6aa5f1 -0.11 0.10 californiacollegeofthearts
+cv-violet_grid 0:7e2c21bb41db -0.12 0.10 sanfrancisco,ca|2014-2018
+cv-violet_grid 0:b6234d2ea0d6 -1.47 0.02 english
+cv-violet_grid 0:bef32d2c315a -1.48 -0.03 native
+cv-violet_grid 0:bef32d2c315a#1 -1.44 -0.03 native
+cv-violet_grid 0:9c9b13446cd9 -1.44 0.02 spanish
+cv-violet_grid 0:e35242c809c8 -1.41 0.02 portuguese
+cv-violet_grid 0:c5d1de4964d6 -1.41 -0.03 conversational
+cv-violet_grid 0:1de4fede4f63 -1.16 -0.17 idesignwithempathy,iteratewithpu
invoice-classic 0:c26c22b94338 0.49 0.10 graphcomposestudio
invoice-classic 0:c26c22b94338#1 0.30 0.06 graphcomposestudio
invoice-classic 0:2a08ce5567fb 0.35 0.10 18layoutstreet
@@ -4024,25 +4025,25 @@ receipt-modern 0:403a860bfc00 0.03 0.05 invented.
receipt-modern 0:7b14b8d2c5c4 -0.04 0.24 page1of1
rota-cobalt 0:33888e306262 -0.17 -0.38 quay
rota-cobalt 0:f5f481423c57 -0.32 -0.38 quaysidebar
-rota-cobalt 0:d457d4238a16 0.08 0.38 monday31st
-rota-cobalt 0:916a90a10237 0.08 0.44 tuesday1st
-rota-cobalt 0:c602d130bfde 0.08 0.42 wednesday2nd
-rota-cobalt 0:74cf6babb6f6 0.08 0.38 thursday3rd
-rota-cobalt 0:ae6aa379ad0c 0.08 0.28 friday4th
-rota-cobalt 0:a6643f169bfd 0.08 0.72 saturday5th
-rota-cobalt 0:58752a3c0543 0.08 0.28 sunday6th
+rota-cobalt 0:d457d4238a16 0.08 -0.12 monday31st
+rota-cobalt 0:916a90a10237 0.08 -0.16 tuesday1st
+rota-cobalt 0:c602d130bfde 0.08 -0.08 wednesday2nd
+rota-cobalt 0:74cf6babb6f6 0.08 -0.17 thursday3rd
+rota-cobalt 0:ae6aa379ad0c 0.08 -0.17 friday4th
+rota-cobalt 0:a6643f169bfd 0.08 -0.18 saturday5th
+rota-cobalt 0:58752a3c0543 0.08 -0.17 sunday6th
rota-cobalt 0:c4aee96677b0 -0.23 -0.24 /cleancrushedice
-rota-cobalt 0:2895e01ceae0 -0.24 -0.22 machine&area
-rota-cobalt 0:f4e7df025c89 -0.23 -0.15 pianist18:30/clean
+rota-cobalt 0:2895e01ceae0 -0.24 -0.27 machine&area
+rota-cobalt 0:f4e7df025c89 -0.23 -0.20 pianist18:30/clean
rota-cobalt 0:d3acaa767810 -0.24 -0.20 crushedicemachine&
rota-cobalt 0:4a91ee5f0106 -0.25 -0.29 area
-rota-cobalt 0:b913ee5e077a -0.23 -0.02 privatedinner44pax/
-rota-cobalt 0:d89987d22df9 -0.24 -0.16 soulgf/pianist18:30
-rota-cobalt 0:fb980282ae0a -0.23 -0.12 pianist18:30/partner
-rota-cobalt 0:3c1fc038017e -0.24 -0.21 meeting3:30pm
-rota-cobalt 0:cc96b22615e5 0.07 -0.25 pianist19:00
-rota-cobalt 0:bc14a4ce6407 -0.08 1.11 statuslegend
-rota-cobalt 0:1f58b9145b24 -0.02 0.13 request
+rota-cobalt 0:b913ee5e077a -0.23 -0.22 privatedinner44pax/
+rota-cobalt 0:d89987d22df9 -0.24 -0.21 soulgf/pianist18:30
+rota-cobalt 0:fb980282ae0a -0.23 -0.22 pianist18:30/partner
+rota-cobalt 0:3c1fc038017e -0.24 -0.26 meeting3:30pm
+rota-cobalt 0:cc96b22615e5 0.07 -0.30 pianist19:00
+rota-cobalt 0:bc14a4ce6407 -0.08 0.36 statuslegend
+rota-cobalt 0:1f58b9145b24 -0.02 -0.02 request
rota-cobalt 0:b4dc66dde806 -0.02 -0.17 off
rota-cobalt 0:b4dc66dde806#1 -0.01 -0.40 off
rota-cobalt 0:b4dc66dde806#2 -0.01 -0.49 off
@@ -4080,11 +4081,11 @@ rota-cobalt 0:cdc2def9aec4#12 0.84 -0.50 hol
rota-cobalt 0:cdc2def9aec4#13 0.85 -0.53 hol
rota-cobalt 0:cdc2def9aec4#14 0.85 -0.48 hol
rota-cobalt 0:cdc2def9aec4#15 0.85 -0.53 hol
-rota-cobalt 0:08c4242b5f58 -0.02 0.03 stock
-rota-cobalt 0:f8f8922da592 -0.02 -0.04 standby
-rota-cobalt 0:c2fb788c7dee -0.02 0.25 training
+rota-cobalt 0:08c4242b5f58 -0.02 -0.12 stock
+rota-cobalt 0:f8f8922da592 -0.02 -0.09 standby
+rota-cobalt 0:c2fb788c7dee -0.02 -0.05 training
rota-cobalt 0:a18603086e5b -0.02 -0.04 support
-rota-cobalt 0:17ae69cc7e46 -0.04 -0.23 covers
+rota-cobalt 0:17ae69cc7e46 -0.04 -0.38 covers
rota-cobalt 0:412a5c513322 -0.03 -0.21 l104/d50
rota-cobalt 0:304560bf3205 -0.03 -0.15 l33/d57
rota-cobalt 0:ca6ce47ada53 -0.03 -0.19 l32/d76
@@ -4093,7 +4094,7 @@ rota-cobalt 0:7062fafe1dab -0.03 -0.25 l65/d112
rota-cobalt 0:778cb57b62fa -0.03 -0.25 l73/d112
rota-cobalt 0:1b4471920f01 -0.03 -0.18 l72/d83
rota-cobalt 0:288965a1f2c8 -0.13 -0.37 management
-rota-cobalt 0:6a61eb7383e8 -0.02 0.01 rosa
+rota-cobalt 0:6a61eb7383e8 -0.02 -0.09 rosa
rota-cobalt 0:3bb0f0bb5552 -0.01 -0.64 09:00-18:00
rota-cobalt 0:3bb0f0bb5552#1 0.15 -0.68 09:00-18:00
rota-cobalt 0:3bb0f0bb5552#2 0.15 -0.67 09:00-18:00
@@ -4121,7 +4122,7 @@ rota-cobalt 0:e669544e9895 -0.80 0.01 08:00-16:00
rota-cobalt 0:e669544e9895#1 -0.02 0.06 08:00-16:00
rota-cobalt 0:19c30663e8ef -0.74 0.81 16:00-21:00
rota-cobalt 0:19c30663e8ef#1 0.05 0.86 16:00-21:00
-rota-cobalt 0:c9affe7f7587 0.09 0.13 tomas
+rota-cobalt 0:c9affe7f7587 0.09 -0.02 tomas
rota-cobalt 0:6d43df86fe4b 0.10 -0.64 16:00-00:00
rota-cobalt 0:6d43df86fe4b#1 0.10 -0.59 16:00-00:00
rota-cobalt 0:6d43df86fe4b#2 0.15 -0.63 16:00-00:00
@@ -4164,12 +4165,12 @@ rota-cobalt 0:953cb9ba8128 0.55 0.00 mirela
rota-cobalt 0:c1298a6e85c2 0.56 -0.63 12:00-18:00
rota-cobalt 0:c1298a6e85c2#1 0.67 -0.63 12:00-18:00
rota-cobalt 0:86846e84f9bf 0.66 -0.07 sofia
-rota-cobalt 0:effcc54ba75f 0.66 -0.03 karol
+rota-cobalt 0:effcc54ba75f 0.66 -0.08 karol
rota-cobalt 0:5c1e134d2d34 0.77 -0.01 henrik
-rota-cobalt 0:6326e0e8cfda 0.88 0.02 lucia
+rota-cobalt 0:6326e0e8cfda 0.88 -0.08 lucia
rota-cobalt 0:c235e502637c 0.81 -0.37 barbacks
rota-cobalt 0:bd558de5f794 0.83 -0.06 oskar
-rota-cobalt 0:4941d6993ee9 0.83 0.09 teodor
-rota-cobalt 0:1d3d3d6a07bd 0.84 -0.02 vesna
+rota-cobalt 0:4941d6993ee9 0.83 -0.01 teodor
+rota-cobalt 0:1d3d3d6a07bd 0.84 -0.07 vesna
rota-cobalt 0:59f2ff7c7c70 0.03 0.08 timesshownin24-hourformat.shiftc
-rota-cobalt 0:594c41ec0506 0.03 -3.15 quaysidebar|31aug-6sep2026
+rota-cobalt 0:594c41ec0506 0.03 -3.55 quaysidebar|31aug-6sep2026
diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv
index da7ee94ec..2b4a561b0 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv
@@ -25,7 +25,7 @@ cv-slate_orange 1 1 87 86 0.42 1.02 0
cv-teal_pulse 1 1 57 57 0.22 0.64 0
cv-terracotta_rail 1 1 102 102 0.44 0.94 0
cv-timeline_minimal 2 2 89 86 0.45 0.50 0
-cv-violet_grid 1 2 80 77 0.36 21.41 37
+cv-violet_grid 1 1 80 78 0.22 0.44 0
invoice-classic 1 1 56 56 0.17 0.32 0
invoice-classic_long 2 2 166 166 0.22 0.27 0
invoice-consulting 1 1 92 92 0.67 1.01 0
diff --git a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv
index 600da53a7..946759b79 100644
--- a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv
@@ -4279,4 +4279,4 @@ rota-cobalt 0:bd558de5f794 0.87 0.22 oskar
rota-cobalt 0:4941d6993ee9 0.88 0.29 teodor
rota-cobalt 0:1d3d3d6a07bd 0.90 0.32 vesna
rota-cobalt 0:59f2ff7c7c70 0.08 -0.06 timesshownin24-hourformat.shiftc
-rota-cobalt 0:594c41ec0506 0.08 -3.26 quaysidebar|31aug-6sep2026
+rota-cobalt 0:594c41ec0506 0.08 -3.62 quaysidebar|31aug-6sep2026
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index 748a5e5ba..cd0634bdc 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
@@ -4352,21 +4352,81 @@ private static DocumentTextStyle dominantTextStyle(DocumentGraph graph) {
.orElse(null);
}
+ /**
+ * Weighs each piece of text by the style it is written in: a run in its own style, a
+ * paragraph's or list item's plain text in its node's, a table cell's in the style its
+ * cascade resolves to, and a composed cell's node as any other node.
+ *
+ * A paragraph made of runs was weighed in its paragraph's style, which its runs set
+ * aside, and table cells were not weighed at all. Where most of a document's text is in
+ * runs or cells — invoices, proposals, a rota — Normal was elected from the few paragraphs
+ * left, and the body text carried a direct size that restyling Normal did not reach: Word's
+ * editing protocol found the body following Normal in none of {@code ProposalEditorial}'s or
+ * {@code CobaltRota}'s paragraphs, Normal 14pt or 12pt over body text of 10pt and 10.5pt.
+ */
private static void weighTextStyles(DocumentNode node,
java.util.Map weights,
java.util.Map byKey) {
- if (node instanceof ParagraphNode paragraph && paragraph.textStyle() != null) {
- weigh(paragraph.textStyle(), textWeight(paragraph.text()), weights, byKey);
- } else if (node instanceof com.demcha.compose.document.node.ListNode list
- && list.textStyle() != null) {
- long weight = list.items().stream().mapToLong(DocxSemanticBackend::textWeight).sum();
- weigh(list.textStyle(), weight, weights, byKey);
+ if (node instanceof ParagraphNode paragraph) {
+ if (!paragraph.inlineRuns().isEmpty()) {
+ weighRuns(paragraph.inlineRuns(), paragraph.textStyle(), weights, byKey);
+ } else if (paragraph.textStyle() != null) {
+ weigh(paragraph.textStyle(), textWeight(paragraph.text()), weights, byKey);
+ }
+ } else if (node instanceof com.demcha.compose.document.node.ListNode list) {
+ if (!list.nestedItems().isEmpty()) {
+ weighItems(list.nestedItems(), list.textStyle(), weights, byKey);
+ } else if (list.textStyle() != null) {
+ long weight = list.items().stream().mapToLong(DocxSemanticBackend::textWeight).sum();
+ weigh(list.textStyle(), weight, weights, byKey);
+ }
+ } else if (node instanceof TableNode table) {
+ for (List row : TableGrid.resolve(table)) {
+ for (TableGrid.Placement placement : row) {
+ DocumentTableCell cell = placement.cell();
+ if (cell.hasComposedContent()) {
+ weighTextStyles(cell.content(), weights, byKey);
+ } else {
+ long weight = cell.lines().stream().mapToLong(DocxSemanticBackend::textWeight).sum();
+ weigh(resolveCellTextStyle(table, placement), weight, weights, byKey);
+ }
+ }
+ }
}
for (DocumentNode child : node.children()) {
weighTextStyles(child, weights, byKey);
}
}
+ /** Weighs a list's items, a rich one by its runs and a plain one by its label in the list's style. */
+ private static void weighItems(List items,
+ DocumentTextStyle style,
+ java.util.Map weights,
+ java.util.Map byKey) {
+ for (com.demcha.compose.document.node.ListItem item : items) {
+ if (item.isRich()) {
+ weighRuns(item.runs(), style, weights, byKey);
+ } else if (style != null) {
+ weigh(style, textWeight(item.label()), weights, byKey);
+ }
+ weighItems(item.children(), style, weights, byKey);
+ }
+ }
+
+ /** Weighs text runs, each in its own style or, where it has none, in the style it falls back to. */
+ private static void weighRuns(List runs,
+ DocumentTextStyle fallback,
+ java.util.Map weights,
+ java.util.Map byKey) {
+ for (InlineRun run : runs) {
+ InlineTextRun text = textOf(run);
+ DocumentTextStyle style = text == null ? null : text.textStyle() != null ? text.textStyle() : fallback;
+ if (style != null) {
+ weigh(style, textWeight(text.text()), weights, byKey);
+ }
+ }
+ }
+
private static void weigh(DocumentTextStyle style,
long weight,
java.util.Map weights,
@@ -9186,7 +9246,7 @@ private boolean resolveCellDirection(TableNode node, TableGrid.Placement placeme
* The same order the layout pipeline merges in: the table's default, then the
* column's, then the row's, then the cell's own.
*/
- private DocumentTextStyle resolveCellTextStyle(TableNode node, TableGrid.Placement placement) {
+ private static DocumentTextStyle resolveCellTextStyle(TableNode node, TableGrid.Placement placement) {
DocumentTextStyle authored = resolveCellValue(node, placement, DocumentTableStyle::textStyle);
return authored != null ? authored : DEFAULT_CELL_FACE;
}
@@ -9231,7 +9291,7 @@ private static DocumentTextDecoration documentDecorationOf(TextDecoration decora
* then the row's, then the cell's own — applied per field rather than per style object, so
* a table-wide border survives a row that only overrides the fill.
*/
- private T resolveCellValue(TableNode node, TableGrid.Placement placement,
+ private static T resolveCellValue(TableNode node, TableGrid.Placement placement,
Function field) {
T resolved = null;
for (DocumentTableStyle candidate : List.of(
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDocumentStyleTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDocumentStyleTest.java
index 4af1c3ba6..7e3d1db21 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDocumentStyleTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDocumentStyleTest.java
@@ -96,6 +96,40 @@ void aRunThatDiffersShouldKeepSayingSo() throws Exception {
}
}
+ @Test
+ void textWrittenInRunsShouldWeighInTheRunsStyleNotTheParagraphs() throws Exception {
+ // A paragraph made of runs leaves its own style unused: weighed in it, the body text
+ // of a proposal elected the engine's 14pt default and every body run carried 10.5pt.
+ try (XWPFDocument document = exported(page -> {
+ page.addParagraph(p -> p.text("Heading").textStyle(HEADING));
+ page.addParagraph(p -> p.inlineText(LONG_BODY, BODY));
+ })) {
+ CTRPr defaults = document.getStyles().getCtStyles().getDocDefaults()
+ .getRPrDefault().getRPr();
+ assertThat(defaults.getSzArray(0).getVal().toString()).as("the runs' 10.5pt").isEqualTo("21");
+ }
+ }
+
+ @Test
+ void textInTableCellsShouldWeighInTheStyleTheirCellsResolveTo() throws Exception {
+ // An invoice's text is mostly in its tables: unweighed, Normal was elected from the few
+ // paragraphs around them.
+ try (XWPFDocument document = exported(page -> {
+ page.addParagraph(p -> p.text("Invoice").textStyle(HEADING));
+ page.addTable(t -> t
+ .columns(com.demcha.compose.document.table.DocumentTableColumn.auto(),
+ com.demcha.compose.document.table.DocumentTableColumn.auto())
+ .defaultCellStyle(com.demcha.compose.document.table.DocumentTableStyle.builder()
+ .textStyle(BODY).build())
+ .row("Consulting, discovery and delivery", "1,200.00")
+ .row("Platform licence for the quarter", "3,400.00"));
+ })) {
+ CTRPr defaults = document.getStyles().getCtStyles().getDocDefaults()
+ .getRPrDefault().getRPr();
+ assertThat(defaults.getSzArray(0).getVal().toString()).as("the cells' 10.5pt").isEqualTo("21");
+ }
+ }
+
@Test
void theDefaultShouldBeChosenByCharactersNotByParagraphCount() throws Exception {
// Four short headings against one long body paragraph. Counting paragraphs elects
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphMarkTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphMarkTest.java
index 2294ef8b6..e5ddd6883 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphMarkTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphMarkTest.java
@@ -82,7 +82,9 @@ void aRichListItemsMarkCarriesItsLastRunsStyleNotTheLists() throws Exception {
void aHairlineOfTextInACellIsNotAsTallAsBodyText() throws Exception {
// A heading rule drawn as a filled cell holding half-point text: with the mark at the
// document's size, the cell came out a line of body text tall.
- try (XWPFDocument document = export(page -> page.addTable(t -> t
+ try (XWPFDocument document = export(page -> page
+ .addParagraph(p -> p.text("Body text sets the document's size"))
+ .addTable(t -> t
.columns(DocumentTableColumn.fixed(120))
.rowCells(DocumentTableCell.node(new ParagraphBuilder().name("Rule").text(" ").lineSpacing(0)
.textStyle(DocumentTextStyle.DEFAULT.withSize(0.5)).build())
@@ -209,7 +211,7 @@ void aMarkIsSizedAsTheTextThatEndsTheLineNotAsTheParagraphsUnusedStyle() throws
// A contact block: an icon and runs styled on their own, the paragraph's style left at its
// default, on two lines. A mark in that default grew every such line in both editors.
try (XWPFDocument document = export(page -> page
- .addParagraph(p -> p.text("Body text sets the document's size"))
+ .addParagraph(p -> p.text("Body text sets the document's size. ".repeat(12)))
.addParagraph(p -> p
.inlineImage(com.demcha.compose.document.image.DocumentImageData.fromBytes(png()), 14, 14)
.inlineText(" billing@example.com".repeat(12), DocumentTextStyle.DEFAULT.withSize(6))))) {
From b2c78d81e4fcbda0bedc1b714c1235c2b459ef7e Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 5 Oct 2026 08:00:26 +0100
Subject: [PATCH 2/4] fix(docx): elect Normal by face and size before colour,
weigh no empty cell, and style a band's mark
---
.../semantic/docx/DocxSemanticBackend.java | 26 ++++++++++--
.../semantic/docx/DocxDocumentStyleTest.java | 40 +++++++++++++++++++
.../semantic/docx/DocxRunStyleTest.java | 2 +-
3 files changed, 64 insertions(+), 4 deletions(-)
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index cd0634bdc..3b3aa734c 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
@@ -1361,6 +1361,9 @@ private void writeBand(XWPFHeaderFooter part, DocumentHeaderFooter band, boolean
appendTab(para, style);
appendBandSlot(para, band.getRightText(), style, numbers);
}
+ // Its mark in its text's style, as a body paragraph's, so Enter in the band continues it
+ // rather than Normal.
+ styleTheMark(para, style);
if (separated) {
CTPBdr borders = properties.isSetPBdr() ? properties.getPBdr() : properties.addNewPBdr();
CTBorder edge = band.getZone() == DocumentHeaderFooterZone.HEADER
@@ -4346,8 +4349,16 @@ private static DocumentTextStyle dominantTextStyle(DocumentGraph graph) {
for (DocumentNode root : graph.roots()) {
weighTextStyles(root, weights, byKey);
}
- return weights.entrySet().stream()
+ // Face and size first, then colour among the styles of that face and size: body text set
+ // in two colours is one body still, and weighed apart its halves lost to a third style.
+ // ProposalNorthline's 9pt text, in its body colour and a muted one, lost to its 10pt.
+ java.util.Map byFaceAndSize = new java.util.HashMap<>();
+ weights.forEach((key, weight) -> byFaceAndSize.merge(key.withoutColour(), weight, Long::sum));
+ return byFaceAndSize.entrySet().stream()
.max(java.util.Map.Entry.comparingByValue())
+ .flatMap(face -> weights.entrySet().stream()
+ .filter(entry -> entry.getKey().withoutColour().equals(face.getKey()))
+ .max(java.util.Map.Entry.comparingByValue()))
.map(entry -> byKey.get(entry.getKey()))
.orElse(null);
}
@@ -4387,8 +4398,12 @@ private static void weighTextStyles(DocumentNode node,
if (cell.hasComposedContent()) {
weighTextStyles(cell.content(), weights, byKey);
} else {
- long weight = cell.lines().stream().mapToLong(DocxSemanticBackend::textWeight).sum();
- weigh(resolveCellTextStyle(table, placement), weight, weights, byKey);
+ // By its letters alone: a grid of empty cells holds no body text, and
+ // counted at one each would outweigh the document's.
+ long weight = cell.lines().stream().mapToLong(String::length).sum();
+ if (weight > 0) {
+ weigh(resolveCellTextStyle(table, placement), weight, weights, byKey);
+ }
}
}
}
@@ -4465,6 +4480,11 @@ static StyleKey of(DocumentTextStyle style) {
Math.round(style.size() * HALF_POINTS_PER_POINT),
style.color() == null ? null : style.color().color().getRGB());
}
+
+ /** The same face and size, any colour. */
+ StyleKey withoutColour() {
+ return new StyleKey(fontName, halfPoints, null);
+ }
}
/** At least one, so a style used only by empty text still counts as used. */
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDocumentStyleTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDocumentStyleTest.java
index 7e3d1db21..4d51cebb5 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDocumentStyleTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDocumentStyleTest.java
@@ -130,6 +130,46 @@ void textInTableCellsShouldWeighInTheStyleTheirCellsResolveTo() throws Exception
}
}
+ @Test
+ void emptyTableCellsShouldWeighNothing() throws Exception {
+ // A grid of empty cells in the engine's default face holds no body text; counted at a
+ // character each, forty of them outweighed the body's paragraph.
+ try (XWPFDocument document = exported(page -> {
+ page.addParagraph(p -> p.text("Body text, short.").textStyle(BODY));
+ page.addTable(t -> {
+ t.columns(com.demcha.compose.document.table.DocumentTableColumn.auto(),
+ com.demcha.compose.document.table.DocumentTableColumn.auto());
+ for (int i = 0; i < 20; i++) {
+ t.row("", "");
+ }
+ });
+ })) {
+ CTRPr defaults = document.getStyles().getCtStyles().getDocDefaults()
+ .getRPrDefault().getRPr();
+ assertThat(defaults.getSzArray(0).getVal().toString()).as("the body's 10.5pt").isEqualTo("21");
+ }
+ }
+
+ @Test
+ void bodyTextInTwoColoursShouldWeighAsOneFaceAndSize() throws Exception {
+ // ProposalNorthline sets its 9pt text in its body colour and a muted one: weighed apart,
+ // each half lost to the 10pt the rest of the text is set in.
+ DocumentTextStyle muted = DocumentTextStyle.builder()
+ .fontName(FontName.HELVETICA).size(10.5).color(DocumentColor.rgb(110, 110, 120)).build();
+ DocumentTextStyle larger = DocumentTextStyle.builder()
+ .fontName(FontName.HELVETICA).size(12).color(DocumentColor.rgb(24, 28, 38)).build();
+ try (XWPFDocument document = exported(page -> {
+ page.addParagraph(p -> p.text("x".repeat(60)).textStyle(BODY));
+ page.addParagraph(p -> p.text("y".repeat(60)).textStyle(muted));
+ page.addParagraph(p -> p.text("z".repeat(80)).textStyle(larger));
+ })) {
+ CTRPr defaults = document.getStyles().getCtStyles().getDocDefaults()
+ .getRPrDefault().getRPr();
+ assertThat(defaults.getSzArray(0).getVal().toString())
+ .as("120 characters at 10.5pt over 80 at 12pt").isEqualTo("21");
+ }
+ }
+
@Test
void theDefaultShouldBeChosenByCharactersNotByParagraphCount() throws Exception {
// Four short headings against one long body paragraph. Counting paragraphs elects
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRunStyleTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRunStyleTest.java
index 4365f9ad1..392dee556 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRunStyleTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRunStyleTest.java
@@ -102,7 +102,7 @@ void aCodeChipCarriesItsOwnGlyphStyleRatherThanTheParagraphs() throws Exception
// means the chip's monospace face now reaches Word. Its background does not.
List runs = paragraphRuns(flow -> flow.addParagraph(paragraph -> paragraph
.textStyle(BASE)
- .inlineText("call ")
+ .inlineText("to render the document, call ")
.inlineCode("run()")));
assertThat(runs).hasSize(2);
From 65d384eb512ce293e63dc8cfc5146f4deef2f44d Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 5 Oct 2026 08:05:07 +0100
Subject: [PATCH 3/4] docs(docx): record Normal reaching the body in all 62
corpus documents
---
CHANGELOG.md | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1aca91777..31c3627b7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,12 +15,15 @@ follow semantic versioning; release dates are ISO 8601.
proposals, a rota — Normal came from the few paragraphs left, and the body carried a direct
size that restyling Normal did not reach: Normal 14pt over 10pt text in `ProposalEditorial`,
12pt over 10.5pt in `CobaltRota`. Each piece of text now counts in the style it is written
- in, a table cell's in the style its cascade resolves to. Word's editing protocol finds the
- body following Normal in 61 of the 62 corpus documents, against 54; `ProposalNorthline`
- still elects 10pt over its 9pt body. LibreOffice sets `VioletGrid` on one page, as the PDF,
- instead of two, its 37 lines past 2pt down to none; 65 lines move in all, 50 nearer, and the
- 15 further are `CobaltRota`'s by 0.05–0.4pt across. In Word one line moves:
- `CobaltRota`'s "quayside bar" heading, 0.36pt further across.
+ in, a table cell's in the style its cascade resolves to; an empty cell counts for nothing.
+ The face and size with the most text are chosen before the colour, so body text set in two
+ colours is not outvoted: `ProposalNorthline`'s 9pt text, in its body colour and a muted one,
+ lost to its 10pt. A header's or footer's paragraph mark is now in its text's style too. Word's
+ editing protocol finds the body following Normal in all 62 corpus documents, against 54.
+ LibreOffice sets `VioletGrid` on one page, as the PDF, instead of two, its 37 lines past 2pt
+ down to none; 65 lines move in all, 50 nearer, and the 15 further are `CobaltRota`'s by
+ 0.05–0.4pt across. In Word one line moves: the right-hand slot of `CobaltRota`'s footer,
+ "quayside bar | 31 aug – 6 sep 2026", 0.36pt further across.
- **Enter in a DOCX continues the text it follows.** Word gives a new paragraph its mark's
formatting, and the export left the mark of most paragraphs — every one written at an exact
line height — in the document's own size and face. Pressing Enter at the end of a paragraph
From 5e7404a9f7daf94f6974ed5f94d39db886a0dde8 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 5 Oct 2026 08:45:59 +0100
Subject: [PATCH 4/4] test(docx): take the Linux LibreOffice baseline from CI
for Normal elected from runs and cells
---
.../docx-fidelity/libreoffice-linux-lines.tsv | 133 +++++++++---------
.../docx-fidelity/libreoffice-linux.tsv | 2 +-
2 files changed, 68 insertions(+), 67 deletions(-)
diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv
index a8ef2fa80..f2f425791 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv
@@ -1764,45 +1764,46 @@ cv-violet_grid 0:53e5e7c5a884 -0.20 0.10 experience
cv-violet_grid 0:5a0774260e0c -0.08 0.10 2022-present
cv-violet_grid 0:204f460767ff -0.12 0.02 seniorux/uidesigner|novafin(fint
cv-violet_grid 0:318bba211bdc -0.07 -0.28 sanfrancisco,ca
-cv-violet_grid 0:318bba211bdc#1 8.85 -0.28 sanfrancisco,ca
+cv-violet_grid 0:318bba211bdc#1 -0.15 -0.28 sanfrancisco,ca
cv-violet_grid 0:3883e88c63c3 -0.08 0.05 ledend-to-enddesignforapersonalf
-cv-violet_grid 0:ccc00663f089 1.70 0.05 conducteduserresearch,builtperso
-cv-violet_grid 0:7d0f908c7e5f 3.48 0.05 designedwireframes,prototypes,an
-cv-violet_grid 0:9b57c4a85aad 5.27 0.05 establishedandmaintainedascalabl
-cv-violet_grid 0:6894eafb99c2 7.05 0.05 collaboratedwithproductmanagers,
-cv-violet_grid 0:01e4455cec51 8.84 0.10 2020-2022
-cv-violet_grid 0:24d722f0cfbe 8.80 0.02 uxdesigner|brighthealth(healthte
-cv-violet_grid 0:530707fb0897 8.84 0.05 redesignedpatientportalexperienc
-cv-violet_grid 0:b2e27a1d9270 10.62 0.05 plannedandfacilitatedusabilityte
-cv-violet_grid 0:ab80a52d70fe 12.40 0.05 partneredwithengineerstoensurefe
-cv-violet_grid 0:11cbf615189b 14.19 0.05 createdreusablecomponentsandstyl
-cv-violet_grid 0:7e5081a3ed48 15.98 0.10 2018-2020
-cv-violet_grid 0:73f7bbf7cd60 15.94 0.02 juniorux/uidesigner|lumendigital
-cv-violet_grid 0:c6b06045332f 15.99 -0.17 austin,tx
-cv-violet_grid 0:5ea8234925c5 15.98 0.05 supporteddiscoveryworkshops,user
-cv-violet_grid 0:46f00c76528c 17.76 0.05 designedresponsivewebsitesandlan
-cv-violet_grid 0:f3cea36a9d83 19.54 0.05 collaboratedwithcopywritersandde
-cv-violet_grid 0:d94eccbb1fbe 21.31 0.10 selectedprojects
-cv-violet_grid 0:45dc8bf81211 21.26 0.08 novafinmobileapp|personalfinance
-cv-violet_grid 0:d398b29d3dbb 21.31 0.61 2023
-cv-violet_grid 0:e66c1ce573d3 21.17 0.08 ledux/uidesignforbudgeting,goalt
-cv-violet_grid 0:0ed8829e68d0 21.17 0.08 thatinformedasimplifiednavigatio
-cv-violet_grid 0:ee9bd17ebccf 21.17 0.08 22%increaseinweeklyactiveusers.
-cv-violet_grid 0:28b4603d784a 21.25 0.08 brighthealthpatientportal|health
-cv-violet_grid 0:1bea20e1df19 21.30 0.61 2021
-cv-violet_grid 0:999ddb26bed6 21.16 0.08 redesignedkeypatientflowsincludi
-cv-violet_grid 0:073d15633c0b 21.16 0.08 systemandcomponentlibraryusedacr
-cv-violet_grid 0:68bf0109c6f8 21.41 0.10 education
-cv-violet_grid 0:6cb574fa10b6 21.41 0.07 languages
-cv-violet_grid 0:0cf7506cf94a 21.48 0.10 bacheloroffineartsingraphicdesig
-cv-violet_grid 0:00ab7a6aa5f1 21.49 0.10 californiacollegeofthearts
-cv-violet_grid 0:7e2c21bb41db 21.48 0.10 sanfrancisco,ca|2014-2018
-cv-violet_grid 0:b6234d2ea0d6 20.73 0.02 english
-cv-violet_grid 0:bef32d2c315a 37.47 -0.03 native
-cv-violet_grid 0:bef32d2c315a#1 5.21 -0.03 native
-cv-violet_grid 0:9c9b13446cd9 21.96 0.02 spanish
-cv-violet_grid 0:e35242c809c8 23.19 0.02 portuguese
-cv-violet_grid 0:c5d1de4964d6 23.19 -0.03 conversational
+cv-violet_grid 0:ccc00663f089 -0.10 0.05 conducteduserresearch,builtperso
+cv-violet_grid 0:7d0f908c7e5f -0.12 0.05 designedwireframes,prototypes,an
+cv-violet_grid 0:9b57c4a85aad -0.13 0.05 establishedandmaintainedascalabl
+cv-violet_grid 0:6894eafb99c2 -0.15 0.05 collaboratedwithproductmanagers,
+cv-violet_grid 0:01e4455cec51 -0.16 0.10 2020-2022
+cv-violet_grid 0:24d722f0cfbe -0.20 0.02 uxdesigner|brighthealth(healthte
+cv-violet_grid 0:530707fb0897 -0.16 0.05 redesignedpatientportalexperienc
+cv-violet_grid 0:b2e27a1d9270 -0.18 0.05 plannedandfacilitatedusabilityte
+cv-violet_grid 0:ab80a52d70fe -0.20 0.05 partneredwithengineerstoensurefe
+cv-violet_grid 0:11cbf615189b -0.21 0.05 createdreusablecomponentsandstyl
+cv-violet_grid 0:7e5081a3ed48 -0.22 0.10 2018-2020
+cv-violet_grid 0:73f7bbf7cd60 -0.26 0.02 juniorux/uidesigner|lumendigital
+cv-violet_grid 0:c6b06045332f -0.21 -0.17 austin,tx
+cv-violet_grid 0:5ea8234925c5 -0.22 0.05 supporteddiscoveryworkshops,user
+cv-violet_grid 0:46f00c76528c -0.24 0.05 designedresponsivewebsitesandlan
+cv-violet_grid 0:f3cea36a9d83 -0.26 0.05 collaboratedwithcopywritersandde
+cv-violet_grid 0:d94eccbb1fbe -0.29 0.10 selectedprojects
+cv-violet_grid 0:45dc8bf81211 -0.34 0.08 novafinmobileapp|personalfinance
+cv-violet_grid 0:d398b29d3dbb -0.29 0.61 2023
+cv-violet_grid 0:e66c1ce573d3 -0.43 0.08 ledux/uidesignforbudgeting,goalt
+cv-violet_grid 0:0ed8829e68d0 -0.43 0.08 thatinformedasimplifiednavigatio
+cv-violet_grid 0:ee9bd17ebccf -0.43 0.08 22%increaseinweeklyactiveusers.
+cv-violet_grid 0:28b4603d784a -0.35 0.08 brighthealthpatientportal|health
+cv-violet_grid 0:1bea20e1df19 -0.30 0.61 2021
+cv-violet_grid 0:999ddb26bed6 -0.44 0.08 redesignedkeypatientflowsincludi
+cv-violet_grid 0:073d15633c0b -0.44 0.08 systemandcomponentlibraryusedacr
+cv-violet_grid 0:68bf0109c6f8 -0.19 0.10 education
+cv-violet_grid 0:6cb574fa10b6 -0.19 0.07 languages
+cv-violet_grid 0:0cf7506cf94a -0.12 0.10 bacheloroffineartsingraphicdesig
+cv-violet_grid 0:00ab7a6aa5f1 -0.11 0.10 californiacollegeofthearts
+cv-violet_grid 0:7e2c21bb41db -0.12 0.10 sanfrancisco,ca|2014-2018
+cv-violet_grid 0:b6234d2ea0d6 -1.47 0.02 english
+cv-violet_grid 0:bef32d2c315a -1.48 -0.03 native
+cv-violet_grid 0:bef32d2c315a#1 -1.44 -0.03 native
+cv-violet_grid 0:9c9b13446cd9 -1.44 0.02 spanish
+cv-violet_grid 0:e35242c809c8 -1.41 0.02 portuguese
+cv-violet_grid 0:c5d1de4964d6 -1.41 -0.03 conversational
+cv-violet_grid 0:1de4fede4f63 -1.16 22.41 idesignwithempathy,iteratewithpu
invoice-classic 0:c26c22b94338 0.49 0.10 graphcomposestudio
invoice-classic 0:c26c22b94338#1 0.25 0.06 graphcomposestudio
invoice-classic 0:2a08ce5567fb 0.35 0.10 18layoutstreet
@@ -4129,25 +4130,25 @@ receipt-modern 0:403a860bfc00 0.03 0.05 invented.
receipt-modern 0:7b14b8d2c5c4 -0.04 0.14 page1of1
rota-cobalt 0:33888e306262 0.18 0.52 quay
rota-cobalt 0:f5f481423c57 -0.32 0.52 quaysidebar
-rota-cobalt 0:d457d4238a16 0.08 1.23 monday31st
-rota-cobalt 0:916a90a10237 0.08 1.29 tuesday1st
-rota-cobalt 0:c602d130bfde 0.08 1.27 wednesday2nd
-rota-cobalt 0:74cf6babb6f6 0.08 1.23 thursday3rd
-rota-cobalt 0:ae6aa379ad0c 0.08 1.18 friday4th
-rota-cobalt 0:a6643f169bfd 0.08 1.62 saturday5th
-rota-cobalt 0:58752a3c0543 0.08 1.18 sunday6th
+rota-cobalt 0:d457d4238a16 0.08 0.73 monday31st
+rota-cobalt 0:916a90a10237 0.08 0.74 tuesday1st
+rota-cobalt 0:c602d130bfde 0.08 0.77 wednesday2nd
+rota-cobalt 0:74cf6babb6f6 0.08 0.73 thursday3rd
+rota-cobalt 0:ae6aa379ad0c 0.08 0.68 friday4th
+rota-cobalt 0:a6643f169bfd 0.08 0.72 saturday5th
+rota-cobalt 0:58752a3c0543 0.08 0.73 sunday6th
rota-cobalt 0:c4aee96677b0 -0.23 0.66 /cleancrushedice
-rota-cobalt 0:2895e01ceae0 -0.24 0.68 machine&area
-rota-cobalt 0:f4e7df025c89 -0.23 0.75 pianist18:30/clean
+rota-cobalt 0:2895e01ceae0 -0.24 0.63 machine&area
+rota-cobalt 0:f4e7df025c89 -0.23 0.70 pianist18:30/clean
rota-cobalt 0:d3acaa767810 -0.24 0.70 crushedicemachine&
-rota-cobalt 0:4a91ee5f0106 -0.25 0.61 area
-rota-cobalt 0:b913ee5e077a -0.23 0.83 privatedinner44pax/
-rota-cobalt 0:d89987d22df9 -0.24 0.74 soulgf/pianist18:30
-rota-cobalt 0:fb980282ae0a -0.23 0.78 pianist18:30/partner
+rota-cobalt 0:4a91ee5f0106 -0.25 0.56 area
+rota-cobalt 0:b913ee5e077a -0.23 0.68 privatedinner44pax/
+rota-cobalt 0:d89987d22df9 -0.24 0.69 soulgf/pianist18:30
+rota-cobalt 0:fb980282ae0a -0.23 0.63 pianist18:30/partner
rota-cobalt 0:3c1fc038017e -0.24 0.64 meeting3:30pm
-rota-cobalt 0:cc96b22615e5 0.07 0.65 pianist19:00
-rota-cobalt 0:bc14a4ce6407 -0.13 2.01 statuslegend
-rota-cobalt 0:1f58b9145b24 -0.07 1.03 request
+rota-cobalt 0:cc96b22615e5 0.07 0.60 pianist19:00
+rota-cobalt 0:bc14a4ce6407 -0.13 1.26 statuslegend
+rota-cobalt 0:1f58b9145b24 -0.07 0.88 request
rota-cobalt 0:b4dc66dde806 -0.07 0.73 off
rota-cobalt 0:b4dc66dde806#1 -0.06 0.45 off
rota-cobalt 0:b4dc66dde806#2 -0.06 0.36 off
@@ -4185,11 +4186,11 @@ rota-cobalt 0:cdc2def9aec4#12 0.79 0.40 hol
rota-cobalt 0:cdc2def9aec4#13 0.80 0.37 hol
rota-cobalt 0:cdc2def9aec4#14 0.80 0.42 hol
rota-cobalt 0:cdc2def9aec4#15 0.80 0.37 hol
-rota-cobalt 0:08c4242b5f58 -0.07 0.93 stock
-rota-cobalt 0:f8f8922da592 -0.07 0.86 standby
-rota-cobalt 0:c2fb788c7dee -0.07 1.10 training
-rota-cobalt 0:a18603086e5b -0.07 0.86 support
-rota-cobalt 0:17ae69cc7e46 -0.09 0.67 covers
+rota-cobalt 0:08c4242b5f58 -0.07 0.78 stock
+rota-cobalt 0:f8f8922da592 -0.07 0.81 standby
+rota-cobalt 0:c2fb788c7dee -0.07 0.85 training
+rota-cobalt 0:a18603086e5b -0.07 0.81 support
+rota-cobalt 0:17ae69cc7e46 -0.09 0.52 covers
rota-cobalt 0:412a5c513322 -0.08 0.64 l104/d50
rota-cobalt 0:304560bf3205 -0.08 0.65 l33/d57
rota-cobalt 0:ca6ce47ada53 -0.08 0.66 l32/d76
@@ -4198,7 +4199,7 @@ rota-cobalt 0:7062fafe1dab -0.08 0.60 l65/d112
rota-cobalt 0:778cb57b62fa -0.08 0.60 l73/d112
rota-cobalt 0:1b4471920f01 -0.08 0.62 l72/d83
rota-cobalt 0:288965a1f2c8 -0.18 0.53 management
-rota-cobalt 0:6a61eb7383e8 -0.07 0.91 rosa
+rota-cobalt 0:6a61eb7383e8 -0.07 0.76 rosa
rota-cobalt 0:3bb0f0bb5552 -0.06 0.26 09:00-18:00
rota-cobalt 0:3bb0f0bb5552#1 0.10 0.22 09:00-18:00
rota-cobalt 0:3bb0f0bb5552#2 0.10 0.23 09:00-18:00
@@ -4226,7 +4227,7 @@ rota-cobalt 0:e669544e9895 -0.85 0.91 08:00-16:00
rota-cobalt 0:e669544e9895#1 -0.07 0.96 08:00-16:00
rota-cobalt 0:19c30663e8ef -0.79 1.71 16:00-21:00
rota-cobalt 0:19c30663e8ef#1 0.00 1.76 16:00-21:00
-rota-cobalt 0:c9affe7f7587 0.04 1.03 tomas
+rota-cobalt 0:c9affe7f7587 0.04 0.88 tomas
rota-cobalt 0:6d43df86fe4b 0.05 0.26 16:00-00:00
rota-cobalt 0:6d43df86fe4b#1 0.05 0.31 16:00-00:00
rota-cobalt 0:6d43df86fe4b#2 0.10 0.27 16:00-00:00
@@ -4269,12 +4270,12 @@ rota-cobalt 0:953cb9ba8128 0.50 0.90 mirela
rota-cobalt 0:c1298a6e85c2 0.51 0.27 12:00-18:00
rota-cobalt 0:c1298a6e85c2#1 0.62 0.27 12:00-18:00
rota-cobalt 0:86846e84f9bf 0.61 0.78 sofia
-rota-cobalt 0:effcc54ba75f 0.61 0.87 karol
+rota-cobalt 0:effcc54ba75f 0.61 0.82 karol
rota-cobalt 0:5c1e134d2d34 0.72 0.89 henrik
-rota-cobalt 0:6326e0e8cfda 0.83 0.92 lucia
+rota-cobalt 0:6326e0e8cfda 0.83 0.82 lucia
rota-cobalt 0:c235e502637c 0.76 0.53 barbacks
rota-cobalt 0:bd558de5f794 0.78 0.84 oskar
-rota-cobalt 0:4941d6993ee9 0.78 0.99 teodor
-rota-cobalt 0:1d3d3d6a07bd 0.79 0.88 vesna
+rota-cobalt 0:4941d6993ee9 0.78 0.89 teodor
+rota-cobalt 0:1d3d3d6a07bd 0.79 0.83 vesna
rota-cobalt 0:59f2ff7c7c70 0.03 0.08 timesshownin24-hourformat.shiftc
-rota-cobalt 0:594c41ec0506 0.03 -3.15 quaysidebar|31aug-6sep2026
+rota-cobalt 0:594c41ec0506 0.03 -3.60 quaysidebar|31aug-6sep2026
diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv
index 0333713a2..d30ed489d 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv
@@ -25,7 +25,7 @@ cv-slate_orange 1 1 87 86 0.42 1.02 0
cv-teal_pulse 1 1 57 57 0.22 0.64 0
cv-terracotta_rail 1 1 102 102 0.44 0.94 0
cv-timeline_minimal 2 2 89 86 0.45 0.50 0
-cv-violet_grid 1 2 80 77 0.36 21.41 37
+cv-violet_grid 1 1 80 78 0.22 0.44 0
invoice-classic 1 1 56 56 0.12 0.30 0
invoice-classic_long 2 2 166 166 0.20 0.27 0
invoice-consulting 1 1 92 92 0.67 0.96 0