Skip to content

fix(docx): keep a colour's translucency where Word holds it, and flatten and name it where it does not - #865

Merged
DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-colour-alpha
Oct 6, 2026
Merged

DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-colour-alpha

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Why

A translucent colour (DocumentColor.rgba(...), withOpacity(...)) lost its alpha in the DOCX export, and the report said nothing:

  • Text was written at full strength, though Word holds a run's transparency.
  • A panel's fill and borders, a table cell's fill and rules were written at full strength in w:shd and w:tcBorders, which take an RGB and nothing else.
  • A rule (a paragraph border) was flattened against the panel around it or white, so NavySidebar's sidebar rules — white at 115/255 — came out pure white over the navy sidebar the page background paints. A header's separator was flattened against white.

Drawings, page backgrounds and pictures already kept their alpha (DrawingML a:alpha, PNG).

What changed

  • Text keeps its transparency (DocxTranslucency.writeTextAlpha).
    • It is written as Word's text fill, w14:textFill with a transparency (Word's Font → Text Effects → Transparency).
    • w:color keeps the colour as authored. Measured in Word 16.0.20430 and LibreOffice: Word takes the colour from the text fill, LibreOffice from w:color, each with the fill's transparency. A flattened w:color would be lightened twice in LibreOffice.
    • Both editors take the fill from the style a run follows (measured), so a translucent body colour is the Normal style's. A run of an opaque colour under it writes an opaque fill of its own, and so does a list level's marker, or Word would draw them in the style's colour. A paragraph's mark copies its text's fill (copyTextFill), since a nested level's marker is drawn in the mark's style.
    • Once the document is written, DocxTranslucency.settle moves each fill last among its run's properties — a decoration, a chip's shading or a direction is written on a run after its colour. It also marks the namespace mc:Ignorable on each part that holds one: the body, the styles, the numbering, and each header and footer, found through the document's relations since POI lists only the headers and footers it read. A part with none is untouched.
  • Where Word holds an opaque colour only, the colour is flattened against what Word paints under it.
    • Inside a panel or cell the export shaded, that is the shading as written. A panel flattened at its centre is then one colour wherever its content stands: a chip, a rule or a cell inside it composites over it.
    • On the page, it is read from the layout (DocxLayoutMetrics.colourUnder): the fills painted before the block's first fragment, composited at its centre over white. That covers rectangles, ellipses, polygons and paths, through DocxInkOutline's outlines, and table cells, a page background included.
    • A row's own fill is left out, as the export does not write it (row paint).
    • A picture, a barcode, a gradient, a fill under a transform, or a payload it does not know, at that point, leaves the colour unknown, and white stands in.
    • Flattened:
      • a panel's fill;
      • a table cell's fill (colourUnderCell, at the cell's centre, its table's first row on that page cached);
      • a rule drawn as a paragraph border;
      • a chip's run shading, where its paragraph has no shading of its own.
    • A panel's borders and a cell's rules are flattened against the block's own fill where it has one, as the page draws them over it in a pass after the fills.
    • A header's or footer's separator runs across the page over whatever it crosses, so it is flattened against white.
    • A wholly transparent fill writes no shading. A wholly transparent border is drawn in the colour under it, so it keeps its room in Word's row and cell geometry.
    • The colour under is read only for a translucent colour. With no layout, the shared empty index is not asked.
  • The report names each one as translucency, APPROXIMATED: once per panel, once per table, per rule, and once per separator however many header parts it is written into. A chip's flattened fill is named on its inline chip note, as before.
  • The flattening helpers live in DocxTranslucency. toHexColor uses its hex.
  • Ledger (DocxNodeFieldLedgerTest): these move to REPORTED, each naming its translucency case:
    • a container's and a section's fillColor, stroke and borders;
    • a shape container's fill and stroke;
    • a line's stroke and a shape's fillColor;
    • a table's rows and cell styles;
    • a paragraph's inlineRuns, and a list's items and nestedItems, for a chip's fill;
    • the headersAndFooters option.
  • Docs:
    • a "Translucent colours" section in the DOCX recipe;
    • the translucency recipe, which said the DOCX export ignores alpha and that the PDF backend draws text and lines opaque;
    • the capability matrix: its alpha row goes from ❌ to ⚠️, and the panel, line, table, chip and band rows say what is flattened against what;
    • render-docx/README.md;
    • DocumentColor.rgba's Javadoc, which said the DOCX backend renders the colour fully opaque;
    • the CHANGELOG.

Verification

  • ./mvnw -B -ntp install -pl :graph-compose-render-docx → BUILD SUCCESS: 1122 tests, 0 failures, 1 skipped (the property-gated fidelity probe).
  • DocxTranslucencyTest is new, with 26 tests, all laid out (none falls back to an export without a layout). Every expected colour is worked out by hand from the authored channels.
    • Text:
      • translucent text carries w:color as authored and a text fill at its transparency; opaque text has none;
      • a translucent body colour is the Normal style's, and an opaque heading and an opaque list's marker write an opaque fill;
      • a nested item's paragraph mark carries the fill its marker is drawn in;
      • an underlined translucent run's text fill is its last property, and the body part is mc:Ignorable="w14" while the styles part, with no fill, is untouched;
      • under a translucent body colour, every XML part holding a fill — the header and footer bands among them, and a table cell's underlined translucent run — has mc:Ignorable="w14" on its root and each fill last of its properties;
      • an opaque document with a header, a list and a panel writes nothing of Word 2010's namespace in any part.
    • Panels:
      • a panel's fill over a navy page background is flattened against the navy, and against white without one;
      • its translucent borders; its borders over its own opaque fill (495266, not white);
      • a wholly transparent fill (no shading) and border (the navy under it);
      • a ContainerNode's fill;
      • over a table cell in a layer stack, against the cell's fill;
      • over a picture, against white, and still named;
      • split by a page break: each piece one colour, named once.
    • Content in a flattened panel standing over a navy column and the white beside it: a chip (2E5990), a rule (072042) and a translucent cell (074EA6) in its white half composite over the panel as written.
    • Chips:
      • on a flattened panel, over the panel as written;
      • on a page background, over that background;
      • in a filled row, over the page's white, since the row's fill is not written.
    • Tables: a cell flattened against the page background, its rules over its fill (572850), named once; a cell merged across two rows, flattened as one, its covered position too.
    • Rules: a divider over a page background — white at 115/255 over navy is 828896 — and a line over white (7F7F7F); a rule in a filled row, over white.
    • Separators: flattened against white, and named once though written into the first page's and the default header.
    • With no layout: a fill is still named, and a chip in a row on the panel composites over the panel as written.
  • The tests fail without the code they cover. 34 sabotages were run, each breaking one thing, and each made the tests that cover it fail:
    • text (9): the text fill, transparency read as opacity, a run's and a list marker's opaque override, the style's fill, the mark's fill, the fills settled last, mc:Ignorable, a header's and a footer's parts settled;
    • panels (7): fill, borders, borders against their own fill, colour under (twice), report, surface;
    • tables (4): cell fill, rule, rule against its cell's fill, report;
    • rules (2): colour under, report;
    • separators (2): the report, and once per band;
    • transparent fill and border (2);
    • the chip's layout fallback (1);
    • the layout composite (4): shapes, cells, a row's fill left out, an unknown payload as unknown;
    • the written surface first (3): for a chip, a node and a cell.
  • Measured in Word 16.0.20430 and LibreOffice on an exported document, which renders the same in both editors as the engine's PDF:
    • an opaque bold heading over a translucent body style;
    • translucent underlined text;
    • a chip;
    • a nested list under the translucent style.
  • Corpus bytes: the 62 corpus documents exported deterministically (DocxFidelityCorpusTest -Dgraphcompose.docxFidelity=export), SHA-256 per file against the export before the change. 61 are byte-identical; cv-navy_sidebar differs in exactly its four sidebar rules, w:color="FFFFFF" → 858B93, which is white at 115/255 over the sidebar's NAVY (32, 44, 59). Word 16.0.20430 renders the rule under EDUCATION at (133, 138, 146), where the PDF draws (131, 138, 146); before this change Word drew it at (255, 255, 255).
  • Across the corpus the report names the four rules: 955 notes, from 951.
  • Documentation guards: core -Dtest='com.demcha.documentation.**' → 166 tests, 0 failures; qa documentation guards plus DocxPageZoneTest, DocxTransparentWrapperTest, TimelineRailAcrossBackendsTest and RtlAcrossBackendsTest → 50 tests, 0 failures.
  • The full reactor gate was not run; no public signature, POM or workflow file changed.

Known limits

  • What lies under is read at the block's centre. A panel over two fills — half on a sidebar, half off it — is flattened against the one under its centre, and its content is set on that colour.
  • A text header's separator is flattened against white, whatever page background it crosses; so is a block over a picture on the page.
  • Word saves translucent text into a PDF as drawing, without a text layer; the Word file holds the text.
  • Flattened colours stop being translucent: recolour what is under them in Word and they keep the colour they were flattened to. That is what the report names.

Lane: render-docx backend, plus tests and docs.

…ten and name it where it does not

Text is written with its transparency as Word's text fill (w14:textFill),
w:color keeping the colour as authored; an opaque run or list marker under
a translucent Normal style writes an opaque fill of its own, a paragraph's
mark takes its text's fill, and each fill is settled last among its run's
properties on a part marked mc:Ignorable.

A cell's shading, a border and a rule hold an opaque colour only: a
translucent panel fill, table cell fill, rule and chip shading are
flattened against what the layout paints under them (a row's unwritten
fill left out), a panel's borders and a cell's rules against the block's
own fill, a header's separator against white, and each is named in the
report as translucency.
…nt on the panel as written

The header and footer parts made in an export are found through the
document's relations: POI lists in getHeaderList() and getFooterList() only
the parts it read, so their text fills were not moved last and their roots
not marked mc:Ignorable.

Inside a panel or cell the export shaded, a chip, a rule, a panel and a
table cell flatten against that shading as written, so a panel flattened at
its centre is one colour wherever its content stands; the layout is read on
the page. The empty index with no layout is not asked for a cell.
@DemchaAV
DemchaAV merged commit 7201484 into 2.5-dev Oct 6, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-colour-alpha branch October 6, 2026 23:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants