diff --git a/CHANGELOG.md b/CHANGELOG.md index d982ec536..caedcc4fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,45 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export keeps a colour's translucency where Word can hold it, and names where it + cannot.** A translucent colour (`DocumentColor.rgba(...)`, `withOpacity(...)`) was written at full + strength, without a note, on text, a table cell's shading, a panel's fill and borders, and a + table's rules. A rule was flattened against the panel around it or white, and a header's + separator against white, also without a note. + - **Text keeps its transparency**, as Word's text fill (`w14:textFill`), with `w:color` holding + the colour as authored: Word takes the colour from the fill and LibreOffice from `w:color`, + each with the fill's transparency, so both draw the page's tint. + - A translucent body colour is the Normal style's. A run, or a list's marker, of an opaque + colour writes an opaque fill of its own, so it does not take the style's. + - The fill is the last of a run's properties, and each part holding one — the body, the + styles, the numbering, a header or footer — marks its namespace `mc:Ignorable`. + - Word saves such text into a PDF as drawing, without a text layer. + - **Where Word holds an opaque colour only** — a cell's shading, a border, a rule — the colour + is flattened against what Word paints under it. Inside a panel or cell the export shaded, that + is the shading as written; on the page, it is read from the layout: the fills drawn before the + block at its centre, a page background included, a row's own fill (which is not written) left + out. Each is named in the report as `translucency`: + - a panel's fill and borders, once per panel; + - a table cell's fill and rules, once per table; + - a rule drawn as a paragraph border; + - a text header's or footer's separator, once per band, flattened against white, since it + runs across the page. + - A panel's borders and a cell's rules are flattened against the block's own fill, which the + page draws them over. + - A wholly transparent fill writes no shading; a wholly transparent border is drawn in the + colour under it, so it keeps its room in the row. + - A chip's shading is flattened the same way where its paragraph has no shading of its own, + and named on its `inline chip` note. + - On the page, where a picture, a barcode, a gradient or a fill under a transform is under the + block, or it is composed in a table cell, white stands in. + - Drawings, page backgrounds and pictures already kept their alpha. + + Across the DOCX fidelity corpus one document's bytes change: `NavySidebar`'s four sidebar + rules, white at 115/255 over the sidebar's navy (32, 44, 59), are written `858B93` instead of + white. Word renders the rule at (133, 138, 146), where the PDF draws (131, 138, 146) and Word + drew (255, 255, 255) before. The report names the four rules: 955 notes, from 951. The other 61 + documents are byte-identical. + - **A DOCX export's report names a paragraph or a list item the page reads as markdown.** A session reads markdown unless it is told not to (`markdown(false)`). It reads a paragraph, or a list item, of plain text holding a mark of emphasis or code: diff --git a/core/src/main/java/com/demcha/compose/document/style/DocumentColor.java b/core/src/main/java/com/demcha/compose/document/style/DocumentColor.java index 9dd3a5828..ffb2ff85f 100644 --- a/core/src/main/java/com/demcha/compose/document/style/DocumentColor.java +++ b/core/src/main/java/com/demcha/compose/document/style/DocumentColor.java @@ -77,7 +77,10 @@ public static DocumentColor rgb(int red, int green, int blue) { * surface: the PDF backend through a graphics-state alpha constant on * shape fills and strokes, text runs, lines, side borders, and table * paint; the PPTX backend natively in DrawingML. The DOCX backend - * currently renders the colour fully opaque.

+ * keeps it on text, as Word's text fill, and on drawings and pictures; + * where Word holds an opaque colour only — a table cell's shading, a + * border, a rule — it flattens the colour against what the page paints + * under it and names it in its export report.

* * @param red red channel from 0 to 255 * @param green green channel from 0 to 255 diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 8b5e5149a..a49219db7 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -65,14 +65,14 @@ Payload records live in `core` under |---|---|---|---| | Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a centred or right-aligned left-to-right line of its own, of text alone and untracked, that Word sets a point or more wider or narrower at its half-point size has its letters spaced by the difference (`w:spacing`) and its room reckoned from the page's width; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a table text cell's; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights; a `bulletOffset` of spaces becomes the paragraph's indent (`w:ind` left, hanging or first line, by `indentStrategy`) in the flow and in cells, not yet over the flow, in an overlay's left-and-right pair, as a badge's initials or in a header or footer; one with letters in it is not written, its wrapped lines still set after the spaces that cover it; an auto-sized paragraph's text is written at its style's size, not the one the page fits it to; a paragraph a session reads as markdown — the default, unless `markdown(false)` — is written as authored, its marks as letters and none of their style, not yet as the page sets it; a `bookmark(...)` is Word's `HeadingN`, which Word's outline lists by the text of its Word paragraph — an overlay's pair's whole line, one level for both sides — at no level past the ninth. Outside a header or footer, the paragraph's report note (`ParagraphNode`) names each of these where it moves or renames something: the prefix's letters, and the room a path that writes no prefix leaves out where it moves a line; the size written and the size the page fits the text to, to Word's half point; the marks of a paragraph the page read as markdown, where its laid-out lines hold fewer of them than its text, not measured where its lines are not read; an outline title that is not the text Word lists, a level past the ninth that shares it with another, and the right side's entry where the left holds the line's level | | List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ⚠️ the top level only. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — or, with rich items or a drawn marker, as paragraphs; content and nesting are unaffected. With the flag, the top level's marker column is the layout's — the marker's width and `markerGap`, the text and its wrapped lines where the page sets them — where the gap covers what Word may set the marker wider: a picture at its written size, its edges included, or text in the page's face (embedded, or a standard one Word sets in the same widths) grown to its half-point size, half a point clear. A Word list's level then indents and hangs by that column; a list of paragraphs writes the marker, a tab to a stop there, and hangs the item there. Word places content at absolute indents and has no relative-advance primitive, so without the layout's measure the gap could not be honoured; a Word list without the flag that the layout placed and that does not nest takes the page's column too, the spaces the page sets its wrapped lines after, its marker followed by a space (`w:suff`) and an item that wraps measured at Word's half-point size; a list that nests items, a list built as a tree of items (laid out flattened), and a marker the gap does not clear keep the stated column (180 twips, plus 120 per nesting level) — except, in a list of paragraphs, a nested rich item with no marker, which stands where the layout set its text, its measure weighed at Word's half-point sizes, where the layout's items are matched to the list's; a list that nests only such items sets its top level at the page's column too. The report counts, on the list, the items that stand at a stated column, a space past their marker or two spaces a level in, and names a centred or right-aligned list written flush left, a lineSpacing not written where the layout's items are not the list's own and one wraps (in a list composed in a table cell, its wrapping not measured), a continuationIndent not written where an item of a markerless list or a tree of items without the flag wraps or its wrapping is not measured, the rows the page draws as a marker alone for blank items of a flagged list, which are not written, and items the page reads as markdown, whose marks are written as letters | -| Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend` — the fill becomes the run's own `w:shd`, in a paragraph and in a list item alike, so a badge still reads as a badge. What Word has no way to say is the shape: shading covers the glyph box, so the corner radius and the padding above and below the letters are not in the file, and the export records them. The padding beside the letters is written as the room it takes (`spaceAfterTheLastLetter`): character spacing after the chip's last letter, shaded with it, and after the letter before the chip, unshaded. A chip opening its line or following a picture has no letter before it, so its left padding is not in the file; no space is written after right-to-left letters or after a symbol or emoji. The export records, chip by chip, how each side was written. LibreOffice sets no spacing after a line's last letter, so it does not apply the right padding of a chip that ends a line. A `w:shd` fill is opaque, so a translucent chip is flattened first against what this export wrote underneath it — the paragraph's shading, the cell's, or the page — so the chip agrees with the file it is in, which on a white page is the colour the PDF shows. It stops being translucent, and that is recorded with the rest | +| Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend` — the fill becomes the run's own `w:shd`, in a paragraph and in a list item alike, so a badge still reads as a badge. What Word has no way to say is the shape: shading covers the glyph box, so the corner radius and the padding above and below the letters are not in the file, and the export records them. The padding beside the letters is written as the room it takes (`spaceAfterTheLastLetter`): character spacing after the chip's last letter, shaded with it, and after the letter before the chip, unshaded. A chip opening its line or following a picture has no letter before it, so its left padding is not in the file; no space is written after right-to-left letters or after a symbol or emoji. The export records, chip by chip, how each side was written. LibreOffice sets no spacing after a line's last letter, so it does not apply the right padding of a chip that ends a line. A `w:shd` fill is opaque, so a translucent chip is flattened first against what Word paints underneath it — the paragraph's shading, the cell's, or else the colour the page paints under the paragraph, a page background included — so the chip agrees with the file it is in and shows the colour the PDF shows. It stops being translucent, and that is recorded with the rest | | Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ✅ `DocxSemanticBackend.writeInlinePicture` (a picture in its own run where it sits among the words, at its size, inside the run's or the paragraph's link; raised or lowered by `w:position` to where the page's alignment and `baselineOffset` put it, from the layout's measure of the paragraph's first line — in a list, the list's text on a line as tall as the item's own tallest picture; LibreOffice ignores `w:position` on a picture and stands it on the baseline, so a picture the export draws itself (icon, emoji, shape) that the page raises carries the rise as transparent rows and needs no `w:position`, while one the page lowers stands in LibreOffice higher than on the page by as much as the page lowers it — up to the text's descent for a centred icon as tall as its line; the editor clips a picture to an exact line height, so a paragraph holding a picture that leaves its text — past the ascent or the descent, in Word's placement or on the baseline — has its lines written at least the height the picture reaches, grown by the editor rather than clipped, every line of the paragraph since Word has one line height for it, and each as tall as the editor's font makes it — for 14pt text about 2.5pt taller than the page's in LibreOffice; a picture inside its text in both editors keeps the exact height; a paragraph of one line of text in a Word paragraph of its own, with room above for its pictures' reach, keeps an exact line at the page's height of it, the pictures set in it where the page puts them in Word and what their ink reaches past it taken from the gaps around it, and in LibreOffice a lowered picture there stands higher and loses what passes the line's top; its description is the text it stands for or empty) | | Inline vector shapes (`ParagraphShapeSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` (distinct per-corner radii render with the top-left radius — single-adjust preset) | ⚠️ `DocxSemanticBackend.writeInlinePicture` + `DocxShapePictures` (a transparent PNG drawn by the shared `InlineSvgRasters` from the outline, fill and stroke — every outline kind, each layer centred in the run's box — placed as an inline picture is; the picture takes as far as the stroked ink reaches past the outline — half the stroke on an edge, more at a sharp corner's miter — and a pixel on each side, measured side by side, and is lowered by what it takes below, so no edge is cut and a shape takes that much more room in the line; a list marker that draws a disc is its picture; at the top level of a `hangingIndent(true)` list that does not nest it is followed by a tab to where the layout starts the item's text, the item's lines hanging there, when the picture clears that stop, else by a space) | | Inline SVG (`ParagraphSvgSpan`) | ✅ `PdfParagraphFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` + `PptxInlineSvgRasterizer` (simple layers stay native; arbitrary clips, exact dash/cap/join styles, and off-viewBox art use a transparent PNG fallback — drawn by the shared `InlineSvgRasters`; gradient paints use their primary colour) | ⚠️ `DocxSemanticBackend.writeInlinePicture` (always the transparent PNG: the same layers the layout resolves, through `InlineSvgLayers`, drawn by the raster the PPTX fallback uses — `InlineSvgRasters`, four pixels a point — and placed as an inline picture is; emoji included, so an emoji is a picture rather than a character, reported `APPROXIMATED`) | | Text an inline icon stands for — copy, search, extraction (`ParagraphSvgSpan.text`, set by `SvgIcon.withText` and on every `EmojiLibrary` emoji) | ✅ `PdfTextLayer` via `PdfRenderEnvironment.writeTextLayer`: one invisible glyph over the icon on the line's baseline, from a Type 3 font of empty glyphs whose `ToUnicode` states each text, a whole ZWJ sequence included; rendering mode 3, so nothing is painted. One font per document, a new one after 255 distinct texts; a text over 256 UTF-16 units is not written. A block icon (`addSvgIcon`, `SvgIcon.node`) writes no text. `ActualText` around the paths was measured to reach none of PDFBox, poppler, pdf.js and MuPDF — it replaces glyphs, and a drawing has none. In a right-to-left line the glyph sits between the words it was written between and states its whole text; reading such a line back, PDFBox reverses the emoji one UTF-16 unit at a time and poppler reverses the code points of a ZWJ sequence or a U+FE0F pair, while pdf.js and MuPDF keep it whole (measured; a reader-side reversal of the glyph's text) | ❌ the icon is drawn and its text is not written | ⚠️ the icon is a picture whose description (`docPr/@descr`) is its text — read by a screen reader, but not a character a reader copies or searches | -| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them — in the body, the left margin is the whole padding, the table no wider on that side, and its indent places its text, as Word 16 and LibreOffice on Windows centre a body table's left border on its edge (an older LibreOffice, the Linux CI's, reads the indent as the table's edge and sets the text about a padding right). A panel with less space above it than its top border — such as one opening a table cell — takes what of the border neither that space nor its padding holds from the space above its first line, where that line has some, and where its row holds the page's height that height is less the borders Word draws outside it, since Word starts a cell's content below its top border or its top margin, whichever is wider (LibreOffice, where `MerchantInvoice`'s panel's content sets its height, draws it that border's width shorter). The table takes the width the layout placed the container at, plus a point of editor slack; in a cell, a panel whose left border hangs half its width left of the cell's text gives up what runs past the cell's edge as Word starts it, half that border in — no more than its left side had given its text, the half border the table is wider by and what its left margin gave back of the other half —, since a nested table running past its cell widens the cell, fixed layout or not. A `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a floating DrawingML shape behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, each side border a line along its edge; a gradient paint and a transform are not carried, nor are a link, an outline entry or an anchor's bookmark — each named in the shape's report note, as unequal corners are —, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in the paragraph whose text it stands beside, placed down from its top, so it moves with that text when the text above is edited; beside no text, in a body paragraph on its page (a table cell's only on a page with no other, reported), where it stays — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws, with no margins, on one page and painting nothing outside its box; in a composed cell, a layer stack of shapes with no margin or padding, the only drawing the cell holds, matched to the table's fragments inside the cell by kind and size), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row; a drawing holding a line stays on the page —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` | +| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them — in the body, the left margin is the whole padding, the table no wider on that side, and its indent places its text, as Word 16 and LibreOffice on Windows centre a body table's left border on its edge (an older LibreOffice, the Linux CI's, reads the indent as the table's edge and sets the text about a padding right). A panel with less space above it than its top border — such as one opening a table cell — takes what of the border neither that space nor its padding holds from the space above its first line, where that line has some, and where its row holds the page's height that height is less the borders Word draws outside it, since Word starts a cell's content below its top border or its top margin, whichever is wider (LibreOffice, where `MerchantInvoice`'s panel's content sets its height, draws it that border's width shorter). The table takes the width the layout placed the container at, plus a point of editor slack; in a cell, a panel whose left border hangs half its width left of the cell's text gives up what runs past the cell's edge as Word starts it, half that border in — no more than its left side had given its text, the half border the table is wider by and what its left margin gave back of the other half —, since a nested table running past its cell widens the cell, fixed layout or not. A `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. A translucent fill is flattened against what Word paints under the panel: the panel or cell around it as written, or on the page the colour the layout paints there (`DocxLayoutMetrics.colourUnder`: the fills drawn before it, at its centre, a page background included and a row's own fill, which is not written, left out). A translucent border is flattened against the panel's fill where it has one, since a cell's shading and borders are opaque; each is named in the report as `translucency`. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a floating DrawingML shape behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, each side border a line along its edge; a gradient paint and a transform are not carried, nor are a link, an outline entry or an anchor's bookmark — each named in the shape's report note, as unequal corners are —, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in the paragraph whose text it stands beside, placed down from its top, so it moves with that text when the text above is edited; beside no text, in a body paragraph on its page (a table cell's only on a page with no other, reported), where it stays — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws, with no margins, on one page and painting nothing outside its box; in a composed cell, a layer stack of shapes with no margin or padding, the only drawing the cell holds, matched to the table's fragments inside the cell by kind and size), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row; a drawing holding a line stays on the page —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` | | Ellipse (`EllipseFragmentPayload`) | ✅ `PdfEllipseFragmentRenderHandler` | ✅ `PptxEllipseFragmentRenderHandler` | ⚠️ `DocxDrawings` — an `ellipse` shape anchored as a rectangle is; a shape container's elliptical outline is drawn the same way, and a picture that fills the container it clips takes the ellipse as its geometry; a transform is not carried, nor a link, an outline entry or an anchor's bookmark (each named in the ellipse's report note), and a shape in a filled panel is drawn in front of the text, over the cell's shading, unless it frames text or a picture; a shape is anchored as a rectangle is — except, as for a rectangle, a drawing that is all a table cell holds — in a row of the flow, a badge alone beside its text — which is anchored in that cell and moves with its row | -| Line — dash pattern, line cap (`LineFragmentPayload`) | ✅ `PdfLineFragmentRenderHandler` | ⚠️ `PptxLineFragmentRenderHandler` (numeric dash arrays map to the generic dashed preset; solid lines and caps exact) | ⚠️ `DocxSemanticBackend.writeRule` — a horizontal line with no transform is Word's own rule: an empty paragraph whose bottom border is the stroke (colour, thickness in eighths of a point, clamped to Word's 12pt), its ends as the paragraph's indents and the space above and below the stroke in its box as the paragraph's height and the space owed below it; a dash pattern becomes Word's dashed or dotted border, reported `APPROXIMATED`; a translucent stroke is flattened against what lies under it; the line cap and a link are not carried (both reported, a link as `rule link`; an outline entry on it is reported too). A vertical or slanted line, and a line laid over others in a layer stack, canvas or shape container — a line among the text of a layer stack of one layer excepted, which is a rule —, is drawn by `DocxDrawings` as a `line` shape anchored as a rectangle is — the dash pattern, cap and a transform are not carried, nor a link, an outline entry or an anchor's bookmark (each named in the line's report note); a line in a page zone is dropped and reported | +| Line — dash pattern, line cap (`LineFragmentPayload`) | ✅ `PdfLineFragmentRenderHandler` | ⚠️ `PptxLineFragmentRenderHandler` (numeric dash arrays map to the generic dashed preset; solid lines and caps exact) | ⚠️ `DocxSemanticBackend.writeRule` — a horizontal line with no transform is Word's own rule: an empty paragraph whose bottom border is the stroke (colour, thickness in eighths of a point, clamped to Word's 12pt), its ends as the paragraph's indents and the space above and below the stroke in its box as the paragraph's height and the space owed below it; a dash pattern becomes Word's dashed or dotted border, reported `APPROXIMATED`; a translucent stroke is flattened against the colour the page paints under it, a page background included, reported as `translucency`; the line cap and a link are not carried (both reported, a link as `rule link`; an outline entry on it is reported too). A vertical or slanted line, and a line laid over others in a layer stack, canvas or shape container — a line among the text of a layer stack of one layer excepted, which is a rule —, is drawn by `DocxDrawings` as a `line` shape anchored as a rectangle is — the dash pattern, cap and a transform are not carried, nor a link, an outline entry or an anchor's bookmark (each named in the line's report note); a line in a page zone is dropped and reported | | Polygon (`PolygonFragmentPayload`) | ✅ `PdfPolygonFragmentRenderHandler` | ✅ `PptxPolygonFragmentRenderHandler` + `PptxInlineGeometry` | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom`, the vertex ring closed, anchored as a rectangle is | | Free path — segments, dash, cap, join (`PathFragmentPayload`) | ✅ `PdfPathFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxPathFragmentRenderHandler` + `PptxInlineGeometry` (numeric dash arrays map to the dashed preset) | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom` through the same move/line/cubic/close segments, an unfilled path left open; the fill and stroke colours are carried, a gradient paint, the dash pattern, cap and join are not; the SVG icons of a block (`addSvgIcon`) are drawn this way, one shape per layer, unclipped | | Linear gradient fill (`DocumentPaint`) | ✅ `PdfShadingSupport` | ✅ `PptxGradientFill` (native `gradFill`; explicit-axis endpoints approximate to the angle) | ❌ | @@ -80,13 +80,13 @@ Payload records live in `core` under | Gradient strokes | ✅ `PdfPathPainter` (pattern stroking colour) | ✅ `PptxGradientFill` (native `ln`/`gradFill`) | ❌ | | Image — STRETCH / CONTAIN / COVER fit (`ImageFragmentPayload`) | ✅ `PdfImageFragmentRenderHandler` | ✅ `PptxImageFragmentRenderHandler` (COVER via the picture source crop) | ✅ `DocxSemanticBackend.writeImage` (the box comes from `NodeDefinitionSupport.resolveImageDimensions`, the same rule layout applies to `width` / `height` / `scale` and the content-width clamp; CONTAIN is embedded at its fitted size, COVER via the picture source crop as in PPTX, and the picture type is read from the bytes; its left margin and padding are not written, and a picture drawn beside its text is fitted to its box with its padding in it — each named in the report) | | Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (vector: the ZXing bit matrix filled as merged rectangles) | ✅ `PptxBarcodeFragmentRenderHandler` (native freeforms: the same ZXing bit matrix as merged rectangles) | ⚠️ `DocxSemanticBackend.writeBarcode` (a PNG picture of the same ZXing bit matrix through `BarcodeMatrices`, one pixel a cell, in the symbol's two colours with their alpha and at the node's size, its data as the picture's description; it scans, but its data is part of the picture rather than editable, reported `APPROXIMATED`, which also names a link, a transform or its left margin and padding as not carried; an `anchor` is a bookmark on its paragraph; in a page zone it is skipped) | -| Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves: `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; the cell's fill maps to `w:shd` and its stroke to `w:tcBorders` — the engine's default 1pt black rule where the table states none, not Word's thinner grid — its padding to `w:tcMar`, less above and below the room Word makes for the horizontal rules (half of a rule between two rows, the lower row's, to each; the rules above and below the table whole to their row); a row's cells at the row's smallest top and bottom margins, since both editors give every cell the row's largest, the rest of each cell's padding as space above its first paragraph and below its last, down to the largest margin a cell opening with a table or in a vertical merge keeps; the cascaded `textAnchor` maps to `w:vAlign` on every cell and to `w:jc` on a text cell's paragraph, with the engine's default — the vertical middle, on the left, or on the right for a right-to-left cell — and `DEFAULT` at the bottom left, as the renderer draws it; a composed cell is written by the same writers that write its node anywhere, so one built from an image, a list or a table carries it — a nested table is a real `w:tbl` taking the width of the column it sits in, less its own margins and padding, which is the column's rather than the one the page gives it, since the layout reports a composed cell's content under the owner's path; the paragraph Word requires after a nested table is hidden where it ends its cell holding nothing and no space; a fill's opacity is dropped since `w:shd` is opaque; Word re-paginates, so the export states where the layout breaks: every row the layout placed is `w:cantSplit`, `repeatHeader(n)` rows are `w:tblHeader` and keep with the row under them, and a row of blocks is kept whole the same way; the paragraph Word requires after a document's closing table is an ordinary one where the last page has room for two lines below it, so a reader can type below the table, and otherwise a point tall with its mark hidden, so it opens no blank page, reported `APPROXIMATED` since text typed at the end then goes into the table's last cell) | +| Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves: `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; the cell's fill maps to `w:shd` and its stroke to `w:tcBorders` — the engine's default 1pt black rule where the table states none, not Word's thinner grid — its padding to `w:tcMar`, less above and below the room Word makes for the horizontal rules (half of a rule between two rows, the lower row's, to each; the rules above and below the table whole to their row); a row's cells at the row's smallest top and bottom margins, since both editors give every cell the row's largest, the rest of each cell's padding as space above its first paragraph and below its last, down to the largest margin a cell opening with a table or in a vertical merge keeps; the cascaded `textAnchor` maps to `w:vAlign` on every cell and to `w:jc` on a text cell's paragraph, with the engine's default — the vertical middle, on the left, or on the right for a right-to-left cell — and `DEFAULT` at the bottom left, as the renderer draws it; a composed cell is written by the same writers that write its node anywhere, so one built from an image, a list or a table carries it — a nested table is a real `w:tbl` taking the width of the column it sits in, less its own margins and padding, which is the column's rather than the one the page gives it, since the layout reports a composed cell's content under the owner's path; the paragraph Word requires after a nested table is hidden where it ends its cell holding nothing and no space; a translucent fill is flattened against the colour the page paints under the cell, and a translucent rule against the cell's fill where it has one, since `w:shd` and `w:tcBorders` are opaque, and the table's report note names it (`translucency`); Word re-paginates, so the export states where the layout breaks: every row the layout placed is `w:cantSplit`, `repeatHeader(n)` rows are `w:tblHeader` and keep with the row under them, and a row of blocks is kept whole the same way; the paragraph Word requires after a document's closing table is an ordinary one where the last page has room for two lines below it, so a reader can type below the table, and otherwise a point tall with its mark hidden, so it opens no blank page, reported `APPROXIMATED` since text typed at the end then goes into the table's last cell) | | Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ `PptxClipSafety` + raster fallback in `PptxFixedLayoutBackend` — a provably no-op clip (padded content that cannot be cut) skips the fallback entirely and stays native, editable shapes; a clip that can cut ink renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; run-level link hotspots are not emitted and custom fragment handlers do not apply inside the picture; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; the raster targets a 2048px long edge, clamped to between native size and 4x, so a region larger than that is rendered at native resolution rather than downscaled — which also means its transient memory grows with the clip instead of stopping at the target (a 3370pt A0-landscape region costs ~45MB while rendering, against ~17MB for anything up to 2048pt); a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning; the report names a clip that cuts what its layers paint, measured from the layout's fragments by `DocxClipInk` — a clip that cuts nothing (an icon inside its box, a disc's initials, a photo filling its circle) is not named; a picture that fills a container clipped to an ellipse takes the ellipse as its geometry, which both editors crop it to; a badge's glyph — a smaller picture in a painted container that clips it to its outline (`CLIP_PATH`) and holds nothing else but drawing — is drawn by `DocxDrawings` as a floating picture over the outline, where the layout places it, reported `APPROXIMATED` — inside a filled panel the badge and its glyph are drawn in front of the shading; an icon picture beside its text in an unpainted container or a layer stack is drawn the same way; a filled or outlined rectangle or rounded rectangle holding text, composed in a table cell, which has no place in the layout to be drawn at, is written as a panel — a one-cell table in its fill and outline, its corners squared and reported, its row held at least the outline's height less the borders both editors draw outside it where its padding does not hold its top border, and a one-line label the shape centres top to bottom on a line taller than the room Word leaves its content cut alike on both sides to that room, no closer to its letters than three quarters of a point, and seated where the page sets it; the rest of what a composed cell draws (an icon, a tile, a disc) is the table's own drawing and is drawn by `drawCellDrawing` where the layout puts it, anchored as a rectangle is | | Timeline rail — one logical connector line resolved from marker and entry anchors after layout (`ShapeFragmentPayload` per page) | ✅ `PdfShapeFragmentRenderHandler` — one fragment per page, spliced beneath the markers | ✅ `PptxShapeFragmentRenderHandler` — same payload, same per-page fragments | ⚠️ `DocxDrawings` — the rail is read from the resolved layout's pass fragments and drawn per page as a `line` shape, and the markers as the shapes they are, anchored as a rectangle is: beside an entry's text they move with it when the text above is edited | | Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | ✅ `PptxTransformBegin/EndRenderHandler` (group shape; rotation and centre-pivot scaling via the exterior/interior frame ratio) | ⚠️ inline fallback + one-time capability warning; a turned shape container is written upright, and the report names its transform — in its drawn outline's note, or on its own where the outline draws nothing | | Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ✅ `PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) | ✅ `DocxSemanticBackend` — an anchor becomes a `w:bookmarkStart` / `w:bookmarkEnd` pair wrapping the paragraph's text, named as Word requires (letters, digits and underscores, starting with a letter, 40 characters); two anchors that clean to one name stay two bookmarks | | Bookmark markers (`BookmarkMarkerPayload`) | ✅ `PdfBookmarkMarkerRenderHandler` + `PdfBookmarkOutlineWriter` | ⚠️ `PptxBookmarkMarkerRenderHandler` + `PptxNavigationWriter` (PPTX has no outline tree — the first bookmark on a page names its slide, further bookmarks on the same page are dropped with a debug note) | ✅ `DocxSemanticBackend` — the stated outline level becomes Word's own `HeadingN` style, so the Navigation Pane, the outline view and a generated table of contents all see the document's structure. The style carries the outline level and no formatting, so the paragraph keeps the look its author gave it; only the levels the document uses are defined, and one past Word's nine is clamped. The role is never inferred from type size | -| Alpha / opacity | ✅ `PdfAlphaSupport` (`PDExtendedGraphicsState` on every surface — shape fills/strokes, text runs, lines, side borders, table paint) | ✅ native `` via POI on every surface — fills, strokes, text runs, table paint | ❌ | +| Alpha / opacity | ✅ `PdfAlphaSupport` (`PDExtendedGraphicsState` on every surface — shape fills/strokes, text runs, lines, side borders, table paint) | ✅ native `` via POI on every surface — fills, strokes, text runs, table paint | ⚠️ `DocxTranslucency` — text keeps its alpha as Word's text fill (`w14:textFill` with a transparency, the last of the run's properties, its namespace marked `mc:Ignorable` on the part; `w:color` keeps the colour as authored, which LibreOffice reads with the fill's transparency; an opaque run or list marker under a translucent Normal style writes an opaque fill of its own), and a drawing's fill and outline, a page background and a picture keep theirs; a cell's shading, a border, a rule, a run's shading (a chip) and a text header's separator hold an opaque colour only, so a translucent one is flattened against what Word paints under it — inside a panel or cell the export shaded, that shading as written; on the page, the colour the layout paints there, a page background included and a row's unwritten fill left out, or white where a picture, barcode, gradient or transformed fill is under it; a panel's border or a cell's rule against the block's own fill; white under a separator — and named in the report as `translucency` (a chip's on its `inline chip` note). Word saves translucent text into a PDF as drawing, with no text layer | | Text decorations — underline / strikethrough (`DocumentTextDecoration`) | ✅ `PdfTextDecorations` (em-proportional marks: underline −0.10 em, strikethrough +0.28 em, thickness 0.05 em) | ✅ `PptxTextFrames.applyStyle` (PowerPoint draws its own marks — sub-point placement differences vs the PDF's constants) | ✅ `DocxSemanticBackend.applyStyle` (underline maps to Word's single underline, strikethrough to `w:strike`) | | Writing direction — right-to-left paragraphs (`ParagraphBuilder.direction`, `TextDirection`) | ✅ `ParagraphWrapping` resolves the line with the Unicode Bidirectional Algorithm and `PdfParagraphFragmentRenderHandler` draws it reordered — the page is painted, so the engine owns the order | ⚠️ `PptxParagraphFragmentRenderHandler` — a right-to-left line goes through **per-span absolute frames** rather than one flowing frame, each pinned where the layout put it, because a shared frame lets PowerPoint re-flow the runs and undo the resolved order. Every frame this handler emits — plain span and chip text alike — declares its direction (`rtl`), which is what puts a neutral on the correct side. A table cell declares it too, through the overload of `PptxTextFrames.singleRunBox` that takes a direction. A header/footer and a watermark still take the overload that declares nothing, so right-to-left text there shows the original defect. The deviation is that the line is not one editable paragraph, and that the text a reader copies out carries mirrored punctuation (see the mirroring row) | ✅ `DocxSemanticBackend.applyParagraphProperties` writes `w:bidi` (resolving `AUTO` through the same `ParagraphDirection` the page used) and hands Word logical text for its own bidi engine, which orders and joins it. Every run of that paragraph also carries `w:rtl`: `w:bidi` settles which edge the line starts from, `w:rtl` settles how Word resolves the characters inside a run, and a run without it is handled as Latin — measured in Word, `(2026)` closing an Arabic line was drawn as `)2026(` with `w:bidi` alone. Hebrew was unaffected, so the defect needed Arabic, where digits after a letter resolve as an Arabic number. Alignment is mapped through the direction, because Word reads `w:jc`'s left/right as start/end **relative to the paragraph** — written physically, a flush-right right-to-left paragraph came out flush left. The indents that carry a container's margin and padding, and a rail timeline body's column, are mapped the same way (`applyDirection` swaps `w:ind` `left` and `right`): Word and LibreOffice both read them as start/end in a `w:bidi` paragraph, and `w:start`/`w:end` read the same — measured, a Hebrew paragraph in a section padded on the left ended short of the right margin by the padding in both. Size and weight are written to the complex-script twins (`w:szCs`, `w:bCs`, `w:iCs`) as well as the Latin ones, since Word takes Hebrew and Arabic from those. Column order in a right-to-left table is not mirrored: `w:tblPr/w:bidiVisual` is unwritten | | Arabic contextual shaping — joined letter forms (`ArabicShaper`) | ✅ shaped into Presentation Forms-B before measurement, because `showText` walks the font `cmap` and never runs `GSUB`; a font carrying the letters but not the forms degrades to unjoined base letters rather than `?` | ✅ base letters restored (`PptxParagraphFragmentRenderHandler` → `ArabicShaper.toBaseLetters`, joining controls kept) — PowerPoint shapes Arabic itself, and frozen forms would land in a file users search and copy from | ✅ never shaped — Word receives the letters and shapes them itself | @@ -114,7 +114,7 @@ honour an option ignores it (documented contract). | Metadata (title, author, …) | ✅ `PdfDocumentPostProcessor` | ⚠️ `applyMetadata` in `PptxFixedLayoutBackend` (OPC core properties + extended `Application`; OPC has no producer field, so that value is not representable) | ⚠️ `applyOutputOptions` (OPC core properties — title, author as creator, subject, keywords; OPC has no producer field here either, so that value is not representable) | | Page backgrounds (`DocumentSession.pageBackgrounds`, `PageBackgroundFill` — full page, columns, bands) | ✅ `DocumentPageBackgrounds` adds each fill as a shape fragment under every page's content, drawn by the ordinary shape handler | ✅ the same fragments, drawn as shapes on every slide | ⚠️ `DocxPageBackgrounds` — each fill is a rectangle anchored to the page, behind the text. A section of more than one page, or with a header or footer, carries them in every header part it has (default, first page, even pages), so they are drawn on every page; a section without a header gets an empty one against the page edge to carry them, and a later section without fills gets an empty header of its own rather than inheriting them. On a page with no top margin LibreOffice still sets the first line about 3pt lower under that header. A section of one page with neither draws them from the body instead — in the first cell's paragraph when the page opens with a table — and its lines stand where the page sets them in both editors (the seven sidebar CVs stood 2.6 to 3.3pt low in LibreOffice); they are on that page alone, so a page an editor's text runs onto has none. A fill's alpha is carried as the shape's. A two-column layout still flows its columns one after the other, so a column fill can stand beside text that is not its column's | | Watermark (front/back layers) | ✅ `PdfWatermarkRenderer` | ✅ `PptxChromeRenderer` (per-slide shape at the PDF placement math; behind-content applies before fragments, so no z-order surgery) | ❌ (not written; reported `DROPPED`, `watermark`) | -| Repeating headers / footers | ✅ `PdfHeaderFooterRenderer` — the zone's `fontName` is resolved through the document's own `FontLibrary`, so a zone draws in the family the author named; unnamed means standard-14 Helvetica, and a code point that family cannot encode is substituted with `?` exactly as body text is | ✅ `PptxChromeRenderer` (positioned per-slide text boxes; `{page}` / `{pages}` / `{date}` tokens with the numbering window rules). The named family reaches the slide run through `PptxFontMapping.familyFor`, and the same family measures the slots — a run measured against one face and typeset in another lands off-centre | ✅ `DocxSemanticBackend.writeBand` (`DocxTextBands`) — one line of a Word header or footer part: the left slot, the centre slot at a centre tab and the right slot at a right tab against the margins; `{page}` / `{pages}` as `PAGE` / `NUMPAGES` (`SECTIONPAGES` per section) fields with the roman or alphabetic switch; `{date}` as the date of the export; the separator as the paragraph's border; the header or footer distance from the band's geometry, baseline within 0.1pt in LibreOffice; a band sharing its kind with another band or a page zone stands in a frame (`w:framePr`) at its own height; `showOnFirstPage(false)` or counting from page 2 → an empty first-page part. A band starting after page 2, numbers not counting from 1 on page 1, and a band alone of its kind reaching past the page margin (written as a negative margin, so that Word holds the body at it as the page does; LibreOffice moves the body clear of it) are reported | +| Repeating headers / footers | ✅ `PdfHeaderFooterRenderer` — the zone's `fontName` is resolved through the document's own `FontLibrary`, so a zone draws in the family the author named; unnamed means standard-14 Helvetica, and a code point that family cannot encode is substituted with `?` exactly as body text is | ✅ `PptxChromeRenderer` (positioned per-slide text boxes; `{page}` / `{pages}` / `{date}` tokens with the numbering window rules). The named family reaches the slide run through `PptxFontMapping.familyFor`, and the same family measures the slots — a run measured against one face and typeset in another lands off-centre | ✅ `DocxSemanticBackend.writeBand` (`DocxTextBands`) — one line of a Word header or footer part: the left slot, the centre slot at a centre tab and the right slot at a right tab against the margins; `{page}` / `{pages}` as `PAGE` / `NUMPAGES` (`SECTIONPAGES` per section) fields with the roman or alphabetic switch; `{date}` as the date of the export; the separator as the paragraph's border, a translucent one flattened against white and reported (`translucency`); the header or footer distance from the band's geometry, baseline within 0.1pt in LibreOffice; a band sharing its kind with another band or a page zone stands in a frame (`w:framePr`) at its own height; `showOnFirstPage(false)` or counting from page 2 → an empty first-page part. A band starting after page 2, numbers not counting from 1 on page 1, and a band alone of its kind reaching past the page margin (written as a negative margin, so that Word holds the body at it as the page does; LibreOffice moves the body clear of it) are reported | | Page zones (node subtree in the band) | ✅ Spliced into the layout graph by `DocumentPageZones`, so the ordinary fragment handlers draw it — no zone-specific code in the backend | ✅ Same splice, same reason: `PptxFixedLayoutBackend.renderGraph` draws every fragment of the graph | ✅ Written into a real `w:ftr` / `w:hdr` part. The band's children become runs on one Word line: a paragraph contributes its runs, a flex spacer becomes the right tab stop, and `PageContext.pageNumber()` / `pageTotal()` become live `PAGE` / `NUMPAGES` fields. Other node kinds are skipped, logged once a kind and reported once each (`DROPPED`, `page zone content`); a zone row's own fill, outline and side borders are reported as `row paint`. Word sets the line's parts one after another from the left margin and, after the first spacer, against the right margin, on one baseline, its tallest part's; the page sets each by the zone's padding, a row's columns and gap, a paragraph's alignment and a part's own sides (a page field's alignment moves nothing: its box is a point wider than its number). The `page zone` note counts the parts the page sets elsewhere — read from the layout's zone fragments: a part stands where the page sets it when its line starts, or against the right margin ends, within a point and a half of Word's, on Word's baseline with the line at the zone's edge, in one line and with no prefix before it on the left side; past a part whose width Word does not keep, or a zone whose nodes the page names or nests otherwise, where a part stands is said to be not measured — and names a zone paragraph's right-to-left direction, prefix letters, fitted size and outline entry, which the line does not carry; the line's height and the room its parts hold above and below are Word's, and a zone paragraph's anchor has no bookmark, none of these yet named. Because Word paginates, `PageContext.number()` refuses here rather than baking a number that would be wrong on every page but one. A zone's `appliesTo` predicate is asked over sample pages (`DocxPageClasses`) and, when it follows Word's first / even / other pages, becomes the matching part — `w:titlePg` for the first page, `w:evenAndOddHeaders` for even pages — with an empty part on the pages it skips; a predicate that picks pages within a kind (the last page) is written on every page and reported | | Protection / encryption | ✅ `PdfDocumentPostProcessor` | ❌ (ignored with a one-time warning — no OOXML encryption support planned) | ❌ (not written, so the file opens unprotected; reported `DROPPED`, `protection`) | | Viewer preferences | ✅ `applyViewerPreferences` in `PdfFixedLayoutBackend` | ❌ (ignored with a one-time warning — PDF-viewer concept) | n/a (not written; reported `DROPPED`, `viewer preferences`) | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 0a333a590..1a682d046 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -67,7 +67,7 @@ creation date is real metadata. | Document node | DOCX output | |---|---| -| Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, as a badge's initials, or in a header or footer; the report names a prefix's letters, and the room a prefix sets lines in by where a path that writes none leaves it out and it moves a line — on the paragraph, or in a header or footer on the zone's note. A session reads markdown unless told not to (`markdown(false)`): it sets a paragraph of plain text's emphasis marks as the style of the text they mark, and drops its marks; the export writes the text as authored, marks and all, which it does not yet write as the page sets it, and the report names it — and, where the paragraph's lines are not read, composed in a table cell whose text the page set otherwise or with no layout, says whether the page reads its marks is not measured, where the page's parser drops one. An auto-sized paragraph (`autoSize`) is written at its style's size, not the one the page fits its text to, and the report names both sizes where Word, to its half point, holds them apart, on the paragraph or, in a header or footer, on the zone's note. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A paragraph in a table cell, an overlay or a list is left to its container | +| Paragraphs | Word paragraphs with alignment, font, size, colour (a translucent one with its transparency, as Word's text fill — see "Translucent colours"), bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, as a badge's initials, or in a header or footer; the report names a prefix's letters, and the room a prefix sets lines in by where a path that writes none leaves it out and it moves a line — on the paragraph, or in a header or footer on the zone's note. A session reads markdown unless told not to (`markdown(false)`): it sets a paragraph of plain text's emphasis marks as the style of the text they mark, and drops its marks; the export writes the text as authored, marks and all, which it does not yet write as the page sets it, and the report names it — and, where the paragraph's lines are not read, composed in a table cell whose text the page set otherwise or with no layout, says whether the page reads its marks is not measured, where the page's parser drops one. An auto-sized paragraph (`autoSize`) is written at its style's size, not the one the page fits its text to, and the report names both sizes where Word, to its half point, holds them apart, on the paragraph or, in a header or footer, on the zone's note. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A paragraph in a table cell, an overlay or a list is left to its container | | Lists | Real Word lists: a `numbering.xml` definition per list, `w:numPr` on each item, and the authored marker as the level's text. Nesting is a list level, so Enter continues the list and Tab demotes an item. See "What a list becomes" below for the kinds that stay plain paragraphs | | Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). A row held at the page's height is written less its margins and those rules too — a rule and a half in the first row and the last, two in a table of one row — since both editors read a row's written height as its cells' content (measured: held less one rule, a table ruled at 0.75pt stood 0.46pt taller in its first row and 0.36pt in its last). A cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one. A table or a row the layout moves to a new page keeps its own top edge there, as the page does — written as a line that tall, kept with it, since Word drops a paragraph's space above at the top of a page — while the gap between it and the block before stays at the foot of the page above, where it fits there; a gap the layout carries onto the new page, because it did not fit at the foot of the page above, is not yet held above a table (body paragraphs and spacers: see their rows). A table's margin is its indent and the space round it, and its padding on the sides holds its rows in as the page draws them: its left side is in the indent too, and both are out of the room its columns are given | | Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table, and whose mark is hidden where it is left at the cell's end holding nothing and no space, since LibreOffice lays it out — and takes the width of the column it sits in, less its own margins and padding — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path | @@ -643,13 +643,55 @@ see which phrase lost what — down to whether its left padding is unshaded spac chip's fill, or not in the file. A `w:shd` fill is opaque, so a translucent chip — `inlineCode(...)` is a fifth-opacity -grey — is flattened first against what the export wrote underneath it: the paragraph's own -shading, the cell's, or the page. Written at full strength the default code chip would be -a solid slab where the page has a tint; flattened, it is the colour the PDF shows. The -chip agrees with the file it is in rather than with the page the PDF drew — a translucent -*container* fill lands opaque too, and a chip on it composites over that. And the chip -stops being translucent: shade that paragraph another colour in Word and it keeps the -tint it was flattened to. That is recorded with the rest. +grey — is flattened first against what Word paints underneath it: the paragraph's own +shading, the cell's, or else the colour the page paints under the paragraph, a page +background included. Written at full strength the default code chip would be a solid slab +where the page has a tint; flattened, it is the colour the PDF shows. The chip agrees with +the file it is in — a translucent *container* fill is flattened too, and a chip on it +composites over what was written. And the chip stops being translucent: shade that +paragraph another colour in Word and it keeps the tint it was flattened to. That is +recorded with the rest. + +## Translucent colours + +A colour with an alpha below full (`DocumentColor.rgba(...)`, `withOpacity(...)`) keeps it +wherever Word holds an alpha, and is flattened where it does not: + +- **Text** keeps it: the run's colour is written with its transparency as Word's text fill + (`w14:textFill`, Font → Text Effects → Transparency in Word), and `w:color` keeps the + colour as authored. Word takes the colour from the text fill and LibreOffice from + `w:color`, each with the fill's transparency, so both draw the page's tint (measured in + Word 16.0 and LibreOffice). A body colour that is translucent is the Normal style's, which + both editors take the fill from; a run of another, opaque colour writes an opaque text fill + of its own, as does a list's marker, so neither takes the style's. The fill is the last of + a run's properties, as Word writes it, and each part holding one — the body, the styles, + the numbering, a header or footer — marks Word 2010's namespace as one a reader that does + not know it may skip (`mc:Ignorable`). Word sets such text as drawing when it saves the + file as a PDF, so that PDF holds no text layer for it; the Word file holds the text. +- **Drawings and pictures** keep it: a shape's fill and outline carry their alpha in + DrawingML, and a page background's too; an inline shape, an icon and a barcode are + pictures with an alpha channel. +- **A cell's shading, a border and a rule** hold an opaque colour only. A translucent panel + fill, a table cell's fill and a rule drawn as a paragraph border are flattened against + what Word paints under them, so the file shows on first opening the colour the PDF shows. + Inside a panel or a cell the export shaded, that is the shading as written: a panel + flattened at its centre is one colour wherever its content stands. On the page, it is the + colour the page paints there — the fills the layout draws before the block, composited at + its centre: a white rule at half strength over a navy sidebar is a pale navy, not white. A + page background counts; a row's own fill does not, as the export does not write it (`row + paint`). A panel's borders and a cell's rules are flattened against the block's own fill + where it has one, since the page draws them over it. A wholly transparent fill is no + shading; a wholly transparent border is drawn in the colour under it, so it keeps its room + in the row. +- **Where the page's colour is not known** — a picture, a barcode or a gradient there, a fill + drawn under a transform, content composed in a table cell, or no layout at all — white + stands in for it. A text header's or footer's separator runs across the page, over + whatever it crosses, and is flattened against white. + +Each flattened colour is named in the export report as `translucency` — once for a panel, +once for a table, for each rule, and once for each separator, however many headers it is +written into; a chip's on its `inline chip` note. What it stops being is translucent: +recolour what is under it in Word and it keeps the colour it was flattened to. ## What falls back @@ -856,7 +898,8 @@ whose bottom border is the stroke, in its colour and thickness, from where the line starts to where it ends, with the space above and below the stroke kept. It flows with the text, and a reader moves or deletes it as a line of the document. A dashed line keeps a dash, in Word's own lengths; a translucent one is -flattened against what lies under it, since a border is opaque. Three limits: +flattened against the colour the page paints under it, since a border is opaque, and the +report names it (see "Translucent colours"). Three limits: - A line laid over something else — a layer in a layer stack of two or more layers or a canvas, such as a skill meter's track and the fill over it — is not a rule in diff --git a/docs/recipes/translucency.md b/docs/recipes/translucency.md index 3131397fb..188e51421 100644 --- a/docs/recipes/translucency.md +++ b/docs/recipes/translucency.md @@ -22,17 +22,21 @@ Opacities outside `[0, 1]` are rejected at construction. ## What honours alpha (and what does not) -In the PDF backend, alpha applies to **shape fills and strokes**: +In the PDF backend, alpha applies on every surface: - rectangles, panels (`softPanel(...)`), and chart bars - ellipses, including chart point markers - polygons (pie/donut slices, line-chart area fills) - inline shapes - chart value-label halo chips - -**Text and lines render fully opaque** regardless of alpha, and the -semantic DOCX export ignores the alpha channel entirely. If a translucent -colour reaches one of those, you get the opaque colour — never an error. +- text runs, lines, side borders, table fills and rules, and barcodes + +The semantic DOCX export keeps the alpha where Word can hold it — text, as +Word's text fill, and drawings and pictures — and flattens it where Word +holds an opaque colour only: a table cell's shading, a border, a rule. A +flattened colour is composited against what Word paints under it, so it looks +as the PDF does on first opening, and the export report names it. See +[Translucent colours](docx-export.md#translucent-colours) in the DOCX recipe. ## Opaque colours stay byte-identical diff --git a/render-docx/README.md b/render-docx/README.md index b460e530d..f50ad716c 100644 --- a/render-docx/README.md +++ b/render-docx/README.md @@ -69,7 +69,8 @@ footer sits from its page edge What maps: - **Text.** Paragraphs keep their alignment and their runs; a run carries its font family, - size, colour, bold, italic, underline and strikethrough. A block's margin and padding + size, colour — a translucent one with its transparency, as Word's text fill — bold, italic, + underline and strikethrough. A block's margin and padding become paragraph spacing. The fonts the document is set in are embedded, where there is a file behind them. - **Lists** are real Word lists — a numbering definition, one level per nesting depth, the @@ -78,8 +79,7 @@ What maps: and text style take the most specific value in the table / column / row / cell cascade; padding becomes the cell's margins and `textAnchor` its alignment; header rows repeat on each page and every row is kept whole. A composed cell is written by the same writers as - anywhere else, so it can hold an image, a list or a nested table. A fill is opaque in - Word. + anywhere else, so it can hold an image, a list or a nested table. - **Rows** are a one-row table whose columns are where the layout placed each child. - **Panels.** A container with a fill or a border is a one-cell table carrying them; rounded corners come out square, and the report says so. @@ -111,6 +111,11 @@ What is not written — each one is named in the export report written as one table row, a cell per column. - **Watermarks, protection and viewer preferences**, each named in the export report. - **A row's own fill, outline and side borders**, named in the export report as `row paint`. +- **Translucency where Word holds an opaque colour only** — a cell's shading, a border, a rule, + a chip's run shading: the colour is flattened against what Word paints under it — the panel or + cell the export shaded, or what the page paints there — and a text header's separator against + white; each is named in the export report (`translucency`, a chip's on its `inline chip` note). + Text, drawings and pictures keep their alpha. - **In a page zone, anything but paragraphs, page fields and spacers** — a logo, a barcode, a rule — named in the export report as `page zone content`. - **Where a page zone's parts stand on Word's line, and what a paragraph in it loses of its diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java index c340422fd..077302ee7 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java @@ -72,6 +72,10 @@ final class DocxLayoutMetrics { private Map> textByPage; // Each page's fragments in paint order, indexed on first use — see clipsOf. private Map> paintedByPage; + // A table's first own row on each page, filled on first use — see colourUnderCell. + private final Map> firstRows = new IdentityHashMap<>(); + // The node at each path, indexed on first use — see aRowsOwnFill. + private Map nodesByPath; private DocxLayoutMetrics(Map paths, Map> fragments, @@ -1206,6 +1210,200 @@ List ownFragments(DocumentNode node) { return fragmentsOf(node); } + /** + * The colour the page shows under a node: what it painted before the node's first fragment, + * at that fragment's centre, each fill laid over the one before on the page's white. + * + *

For a translucent colour Word can only hold opaque, flattened against it, so the file + * shows on first opening the colour the page shows — a white rule at half strength over a + * page's navy sidebar is a pale navy, not white.

+ * + * @param node a node of the graph + * @return that colour, or empty where nothing tells: no layout, no fragment of the node's + * own — content composed in a table cell has none — or something under it whose colour + * at that point the layout does not carry: a picture, a barcode, a gradient, a fill + * under a transform + */ + java.util.Optional colourUnder(DocumentNode node) { + return colourUnder(paths.get(node)); + } + + /** + * The colour the page shows under the node at a path, as {@link #colourUnder(DocumentNode)}. + * + * @param path a node's path, or {@code null} + * @return that colour, or empty where nothing tells + */ + java.util.Optional colourUnder(String path) { + List own = path == null ? List.of() : fragments.getOrDefault(path, List.of()); + if (own.isEmpty()) { + return java.util.Optional.empty(); + } + PlacedFragment first = own.get(0); + return colourUnder(first, first.x() + first.width() / 2, first.y() + first.height() / 2); + } + + /** + * The colour the page shows under one of a table's cells, at the cell's centre, painted + * before the table's first row on the page where the cell first stands. + * + * @param table the table node + * @param row the cell's logical row + * @param column the cell's first column + * @return that colour, or empty where nothing tells, as {@link #colourUnder(DocumentNode)} + */ + java.util.Optional colourUnderCell(DocumentNode table, int row, int column) { + if (isEmpty()) { + // Nothing to tell, and the index with no layout is shared: its caches stay empty. + return java.util.Optional.empty(); + } + List boxes = cellBoxes(table, row, column); + if (boxes.isEmpty()) { + return java.util.Optional.empty(); + } + CellBox box = boxes.get(0); + PlacedFragment firstRow = firstRows.computeIfAbsent(table, node -> { + Map byPage = new HashMap<>(); + for (PlacedFragment rowFragment : ownRows(node)) { + byPage.putIfAbsent(rowFragment.pageIndex(), rowFragment); + } + return byPage; + }).get(box.page()); + return firstRow == null ? java.util.Optional.empty() + : colourUnder(firstRow, (box.left() + box.right()) / 2, (box.bottom() + box.top()) / 2); + } + + /** + * What the page painted at a point before a fragment, over its white, as Word shows it: + * rectangles, ellipses, polygons, paths and table cells in their fill colours, a row's own fill + * left out, as the export does not write it ({@code row paint}). A picture, a barcode, a + * gradient, a fill drawn under a transform, or what the layout paints in a payload this does + * not know, covering the point, leaves the colour unknown. + */ + private java.util.Optional colourUnder(PlacedFragment above, double x, double y) { + List page = paintedOn(above.pageIndex()); + int until = indexOf(page, above); + if (until < 0) { + return java.util.Optional.empty(); + } + java.awt.Color colour = java.awt.Color.WHITE; + int turned = 0; + for (int index = 0; index < until; index++) { + PlacedFragment fragment = page.get(index); + Object payload = fragment.payload(); + if (payload instanceof com.demcha.compose.document.layout.payloads.TransformBeginPayload) { + turned++; + continue; + } + if (payload instanceof com.demcha.compose.document.layout.payloads.TransformEndPayload) { + turned = Math.max(0, turned - 1); + continue; + } + if (payload == null || PAINTS_NO_FILL.contains(payload.getClass()) || aRowsOwnFill(fragment)) { + continue; + } + java.awt.Color fill = solidFillAt(fragment, x, y); + if (!colourKnownAt(fragment, x, y) || (fill != null && turned > 0)) { + return java.util.Optional.empty(); + } + if (fill != null) { + colour = DocxTranslucency.flatten(fill, colour); + } + } + return java.util.Optional.of(colour); + } + + /** + * The payloads that paint no fill: text and strokes are ink over what is under them, and the + * rest mark a place or open and close a clip. + */ + private static final java.util.Set> PAINTS_NO_FILL = java.util.Set.of( + ParagraphFragmentPayload.class, + com.demcha.compose.document.layout.payloads.LineFragmentPayload.class, + ShapeClipBeginPayload.class, + ShapeClipEndPayload.class, + com.demcha.compose.document.layout.payloads.AnchorMarkerPayload.class, + com.demcha.compose.document.layout.payloads.BookmarkMarkerPayload.class, + com.demcha.compose.document.layout.payloads.LayoutAnchorPayload.class); + + /** Whether a fragment is a row's own fill, which the export does not write. */ + private boolean aRowsOwnFill(PlacedFragment fragment) { + if (!(fragment.payload() instanceof com.demcha.compose.document.layout.payloads.ShapeFragmentPayload)) { + return false; + } + if (nodesByPath == null) { + nodesByPath = new HashMap<>(); + paths.forEach((node, path) -> nodesByPath.putIfAbsent(path, node)); + } + return nodesByPath.get(fragment.path()) instanceof com.demcha.compose.document.node.RowNode; + } + + /** + * The solid colour a fragment fills a point with, or null where it fills none there — a + * gradient included, which has no one colour ({@link #colourKnownAt} says so). + */ + private static java.awt.Color solidFillAt(PlacedFragment fragment, double x, double y) { + Object payload = fragment.payload(); + if (payload instanceof TableRowFragmentPayload row) { + java.awt.Color fill = null; + for (TableResolvedCell cell : row.cells()) { + double left = fragment.x() + cell.x(); + double bottom = fragment.y() + cell.yOffset(); + if (cell.style() != null && cell.style().fillColor() != null + && x >= left && x <= left + cell.width() && y >= bottom && y <= bottom + cell.height()) { + fill = cell.style().fillColor(); + } + } + return fill; + } + if (payload instanceof com.demcha.compose.document.layout.payloads.ShapeFragmentPayload shape) { + java.awt.Color fill = shape.fillPaint() == null ? shape.fillColor() : solidColourOf(shape.fillPaint()); + return fill != null && DocxInkOutline.box(fragment, shape.cornerRadius()).contains(x, y) ? fill : null; + } + if (payload instanceof com.demcha.compose.document.layout.payloads.EllipseFragmentPayload ellipse) { + return ellipse.fillColor() != null && DocxInkOutline.ellipse(fragment.x(), fragment.y(), + fragment.width(), fragment.height()).contains(x, y) ? ellipse.fillColor() : null; + } + if (payload instanceof com.demcha.compose.document.layout.payloads.PolygonFragmentPayload polygon) { + return polygon.fillColor() != null + && DocxInkOutline.polygon(polygon.points(), fragment).contains(x, y) ? polygon.fillColor() : null; + } + if (payload instanceof com.demcha.compose.document.layout.payloads.PathFragmentPayload path) { + java.awt.Color fill = path.fillPaint() == null ? path.fillColor() : solidColourOf(path.fillPaint()); + return fill != null && DocxInkOutline.path(path.segments(), fragment).contains(x, y) ? fill : null; + } + return null; + } + + /** + * Whether the colour a fragment paints at a point is one the layout carries: false for a + * gradient over the point, and for a picture, a barcode or a payload this does not know whose + * box holds it. + */ + private static boolean colourKnownAt(PlacedFragment fragment, double x, double y) { + Object payload = fragment.payload(); + if (payload instanceof TableRowFragmentPayload + || payload instanceof com.demcha.compose.document.layout.payloads.EllipseFragmentPayload + || payload instanceof com.demcha.compose.document.layout.payloads.PolygonFragmentPayload) { + return true; + } + if (payload instanceof com.demcha.compose.document.layout.payloads.ShapeFragmentPayload shape) { + return shape.fillPaint() == null || solidColourOf(shape.fillPaint()) != null + || !DocxInkOutline.box(fragment, shape.cornerRadius()).contains(x, y); + } + if (payload instanceof com.demcha.compose.document.layout.payloads.PathFragmentPayload path) { + return path.fillPaint() == null || solidColourOf(path.fillPaint()) != null + || !DocxInkOutline.path(path.segments(), fragment).contains(x, y); + } + return x < fragment.x() || x > fragment.x() + fragment.width() + || y < fragment.y() || y > fragment.y() + fragment.height(); + } + + /** A paint's one colour, or null for a gradient, which has none. */ + private static java.awt.Color solidColourOf(com.demcha.compose.document.style.DocumentPaint paint) { + return paint instanceof com.demcha.compose.document.style.DocumentPaint.Solid solid ? solid.color().color() : null; + } + /** * What a node clips, on each page it opens a clip: the fragment opening the clip, and what * the page paints after it until the clip closes — the node's layers, and anything they 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 5e30cf301..041c611a3 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 @@ -192,6 +192,11 @@ public final class DocxSemanticBackend implements SemanticBackend { private static final String ANCHORED_BESIDE_ITS_TEXT = "anchored in the paragraph whose text it stands " + "beside, so it moves with that text when the text above is edited, or to the page where it " + "stands beside none"; + /** + * The report's subject for a translucent colour Word holds only opaque — a cell's shading, a + * border — flattened against the colour under it (see {@link DocxTranslucency}). + */ + private static final String TRANSLUCENCY = "translucency"; private static final Logger LOG = LoggerFactory.getLogger(DocxSemanticBackend.class); // The page's content width, so an image is held to the same bound layout holds it to. // Set per export; Double.MAX_VALUE means "no canvas, so nothing to clamp against". @@ -209,6 +214,12 @@ public final class DocxSemanticBackend implements SemanticBackend { // kind and no name are two losses. private final java.util.Set zonePartsReported = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + // The text bands whose translucent separator is already reported this export: a band is + // written into each kind of header or footer Word is given. + private final java.util.Set separatorsReported = + java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + // Whether any text fill was written this export, so the finished parts are settled only then. + private boolean textFillsWritten; private final AtomicBoolean containerRadiusWarned = new AtomicBoolean(false); // The fill of the panel being written into, or null: a table cell with no fill of its own // is drawn white by the engine, and inside a filled panel has to say so rather than let the @@ -705,6 +716,8 @@ private byte[] write(List sections, Path outputFile) throws Exc containerRadiusWarned.set(false); warnedNodeKinds.clear(); zonePartsReported.clear(); + separatorsReported.clear(); + textFillsWritten = false; closingBlocks.clear(); keepsUnheld.clear(); followedInFlow.clear(); @@ -816,6 +829,9 @@ private byte[] write(List sections, Path outputFile) throws Exc } hideTheClosingMark(document); hideTheCellClosingMarks(document.getTables()); + if (textFillsWritten) { + DocxTranslucency.settle(document); + } if (deterministicTimestamp != null) { DocxDeterminism.pinCoreProperties(document, deterministicTimestamp); } @@ -1463,9 +1479,19 @@ private void writeBand(XWPFHeaderFooter part, DocumentHeaderFooter band, boolean CTBorder edge = band.getZone() == DocumentHeaderFooterZone.HEADER ? (borders.isSetBottom() ? borders.getBottom() : borders.addNewBottom()) : (borders.isSetTop() ? borders.getTop() : borders.addNewTop()); + // The separator runs the width of the page, over whatever fills the page draws across it, + // so there is no one colour under it to flatten a translucent one against but the page's + // white. + java.awt.Color colour = band.getSeparatorColor().color(); paintEdge(edge, STBorder.SINGLE, BigInteger.valueOf(ruleEighths(band.getSeparatorThickness())), - toHexColor(flatten(band.getSeparatorColor().color(), java.awt.Color.WHITE))); + toHexColor(DocxTranslucency.flatten(colour, java.awt.Color.WHITE))); edge.setSpace(BigInteger.valueOf(Math.round(DocxTextBands.separatorSpace(band)))); + if (DocxTranslucency.translucent(colour) && separatorsReported.add(band)) { + report.add(DocxExportReport.Severity.APPROXIMATED, TRANSLUCENCY, + sectioned ? "section " + (sectionIndex + 1) : null, + "a page " + zoneName(band) + "'s separator is flattened against white, because a " + + "paragraph border is opaque"); + } } } @@ -3481,7 +3507,9 @@ private BigInteger numberingFor(XWPFDocument document, // line in the list's style, so its mark is too (styleTheMark); the level states // the list's face, size and colour as well, the ones the column was measured // against. The page draws the marker in the list's style, decoration included. - applyDefaultRunProperties(level.addNewRPr(), list.textStyle()); + // Over a translucent Normal style an opaque marker writes an opaque fill of its own, as + // a run does, so it does not take the style's. + applyDefaultRunProperties(level.addNewRPr(), list.textStyle(), normalIsTranslucent()); } } BigInteger abstractId = document.createNumbering() @@ -4628,8 +4656,20 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container cellWidth.setType(STTblWidth.DXA); cellWidth.setW(BigInteger.valueOf(toTwips(outer))); } - applyCellPaint(cell, paint.fill(), null); - paintCellSides(cell, paint.borders()); + // Word's shading and borders are opaque: a translucent fill or border is flattened against + // what the page paints under the panel, and the panel's content is set on that. + boolean translucentFill = DocxTranslucency.flattensFill(paint.fill()); + boolean translucentSides = DocxTranslucency.flattensSides(paint.borders()); + // Read from the page's fragments, so only where something is flattened against it. + java.awt.Color under = translucentFill || translucentSides ? colourUnder(node) : java.awt.Color.WHITE; + DocumentColor fill = DocxTranslucency.flattenedFill(paint.fill(), under); + applyCellPaint(cell, fill, null); + // The page draws the borders over the panel's fill, centred on its edge: against the fill + // where the panel has one, the side of them the panel holds. + paintCellSides(cell, DocxTranslucency.flattenedBorders(paint.borders(), fill != null ? fill.color() : under)); + if (first) { + reportFlattenedPaint(node, translucentFill, translucentSides, "its fill", "its borders"); + } DocumentInsets margins = insideTheBorders(padding, borders); applyCellPadding(cell, new DocumentInsets(margins.top(), Math.max(0, margins.right() - shortOfTheEdge), margins.bottom(), inTheBody ? padding.left() : margins.left())); @@ -4644,8 +4684,8 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container cell.removeParagraph(0); DocumentColor outerSurface = surfaceBehind; double outerCellWidth = currentCellWidth; - if (paint.fill() != null) { - surfaceBehind = paint.fill(); + if (fill != null) { + surfaceBehind = fill; } currentCellWidth = Double.isFinite(width) ? width - padding.left() - padding.right() : Double.NaN; XWPFTableCell outerPanelCell = panelCell; @@ -4816,6 +4856,33 @@ private static double strokeWidth(DocumentStroke stroke) { return stroke == null ? 0 : Math.max(0, stroke.width()); } + /** + * Names in the report a node's translucent fill or strokes, which a cell's shading and its + * borders hold only opaque, so they are flattened against the colour under them. + * + * @param fill whether a fill is flattened + * @param strokes whether a stroke is + * @param fillName what the report calls the fill, as "its fill" + * @param strokeName what it calls the strokes, as "its borders" + */ + private void reportFlattenedPaint(DocumentNode node, boolean fill, boolean strokes, + String fillName, String strokeName) { + List flattened = new ArrayList<>(2); + if (fill) { + flattened.add(fillName); + } + if (strokes) { + flattened.add(strokeName); + } + if (!flattened.isEmpty()) { + // "its fill" is one; "its borders", "its cells' fills" and two of them are more. + boolean plural = flattened.size() > 1 || flattened.get(0).endsWith("s"); + report.add(DocxExportReport.Severity.APPROXIMATED, TRANSLUCENCY, layout.pathOf(node), + String.join(" and ", flattened) + (plural ? " are" : " is") + " flattened against the colour " + + "under " + (plural ? "them" : "it") + ", because a Word cell's shading and borders are opaque"); + } + } + /** * Writes a panel's borders on its cell, side by side; a side the panel does not draw is * stated as none, so the cell carries no border the page does not show. @@ -5171,6 +5238,18 @@ private static void writeHeadingStyle(CTStyles styles, int level) { } private void applyDefaultRunProperties(CTRPr properties, DocumentTextStyle defaults) { + applyDefaultRunProperties(properties, defaults, false); + } + + /** + * Writes a style's font, size and colour on run properties: the document's defaults, the + * Normal style's, or a list level's for its marker. + * + * @param overATranslucentStyle whether these properties sit over a Normal style whose colour is + * translucent, which an opaque colour here must override in full + */ + private void applyDefaultRunProperties(CTRPr properties, DocumentTextStyle defaults, + boolean overATranslucentStyle) { if (defaults.fontName() != null) { // All four slots, exactly as XWPFRun.setFontFamily writes them on a run. // w:ascii alone covers only ASCII: High-ANSI characters read w:hAnsi, Hebrew @@ -5192,9 +5271,18 @@ private void applyDefaultRunProperties(CTRPr properties, DocumentTextStyle defau } if (defaults.color() != null) { properties.addNewColor().setVal(toHexColor(defaults.color().color())); + // Both editors take a text fill's transparency from the style a run follows. + textFillsWritten |= DocxTranslucency.writeTextAlpha(properties, defaults.color().color(), + overATranslucentStyle); } } + /** Whether the Normal style's colour is translucent, so its text fill is what a run follows. */ + private boolean normalIsTranslucent() { + return documentDefaultStyle != null && documentDefaultStyle.color() != null + && documentDefaultStyle.color().color().getAlpha() < 255; + } + /** * The family name to write for a style's font, as Word understands families. * @@ -7669,6 +7757,9 @@ private void styleTheMark(XWPFParagraph target, DocumentTextStyle style) { mark.setSzArray(text.getSzArray()); mark.setSzCsArray(text.getSzCsArray()); mark.setUArray(text.getUArray()); + // A list's marker is drawn in the mark's style where its level states none — a nested + // level, a list the layout did not measure — so the mark takes the text's fill too. + DocxTranslucency.copyTextFill(text, mark); } /** Whether a paragraph's lines are written at an exact height, which no mark changes. */ @@ -8890,10 +8981,10 @@ private static InlineBackground backgroundOf(InlineRun run) { *

A {@code w:shd} fill is opaque, and the chip this sugar reaches for most — * {@code code(...)} — is a fifth-opacity grey. Written at full strength it is a solid * slab where the page has a tint, so a translucent fill is flattened first against what - * Word paints underneath it: the paragraph's own shading, the cell's, or the page. The - * chip then agrees with the file it is in — including where that file already differs - * from the page, since a translucent container fill lands opaque too. What it - * stops being is translucent: recoloured underneath in Word, the chip no longer + * Word paints underneath it: the paragraph's own shading, the cell's, or else what the page + * paints under the paragraph ({@link #colourUnder(XWPFRun, String)}). The chip then agrees + * with the file it is in, a translucent container fill under it flattened too. What + * it stops being is translucent: recoloured underneath in Word, the chip no longer * follows.

* *

What Word cannot express is the chip's shape. Shading covers the glyph @@ -8917,7 +9008,8 @@ private void applyInlineBackground(XWPFRun run, List runs, int index, : properties.addNewShd(); shading.setVal(STShd.CLEAR); shading.setColor("auto"); - shading.setFill(toHexColor(flatten(background.fill().color(), colourUnder(run)))); + java.awt.Color fill = background.fill().color(); + shading.setFill(toHexColor(fill.getAlpha() < 255 ? DocxTranslucency.flatten(fill, colourUnder(run, path)) : fill)); String lost = chipLost(background, leftPaddingAs(runs, index, rightToLeft), !rightToLeft && takesSpaceAfter(textOf(runs.get(index)).text())); if (lost != null) { @@ -8967,15 +9059,17 @@ private static String chipLost(InlineBackground background, String leftAs, boole /** * The colour Word will paint under {@code run} — the shading this export itself wrote - * on the run's paragraph or on the cell holding it, then the fill of the panel around an - * unshaded cell, and otherwise the page's white. + * on the run's paragraph, on the cell holding it, or on the panel or cell around an unshaded + * one; on the page, what the page paints under the paragraph ({@link #colourUnder(DocumentNode)}). * *

Read back from the file being written rather than tracked in a field, so it is * whatever was actually written and cannot drift from it. Read, and only read: * {@code cellProperties} would create the {@code w:tcPr} it cannot find, so an * unstyled cell holding a chip would come away carrying an empty one.

+ * + * @param path the paragraph's path, or {@code null} where it has none */ - private java.awt.Color colourUnder(XWPFRun run) { + private java.awt.Color colourUnder(XWPFRun run, String path) { XWPFParagraph para = run.getParent() instanceof XWPFParagraph parent ? parent : null; CTPPr paragraphProperties = para == null || !para.getCTP().isSetPPr() ? null @@ -8994,6 +9088,21 @@ private java.awt.Color colourUnder(XWPFRun run) { if (cellFill != null) { return cellFill; } + return surfaceBehind != null ? surfaceBehind.color() : layout.colourUnder(path).orElse(java.awt.Color.WHITE); + } + + /** + * The colour Word paints under a node: inside a panel or cell this export shaded, that + * shading as written — a panel flattened at its own centre is one colour wherever its + * content stands —; on the page, what the page paints under the node at its centre, where + * the layout tells it ({@link DocxLayoutMetrics#colourUnder(DocumentNode)}), or white. + */ + private java.awt.Color colourUnder(DocumentNode node) { + return surfaceBehind != null ? surfaceBehind.color() : layout.colourUnder(node).orElse(java.awt.Color.WHITE); + } + + /** The fill of the panel or cell being written into, as written; the page's white outside one. */ + private java.awt.Color surfaceColour() { // A cell with no shading of its own — a row's, inside a card — shows the panel's. return surfaceBehind != null ? surfaceBehind.color() : java.awt.Color.WHITE; } @@ -9015,26 +9124,6 @@ private static java.awt.Color shadingFillOf(CTShd shading) { return new java.awt.Color(rgb[0] & 0xFF, rgb[1] & 0xFF, rgb[2] & 0xFF); } - /** - * Composites a colour over what sits beneath it, so a translucent fill survives a - * format that has no alpha. An opaque colour is returned untouched. - */ - private static java.awt.Color flatten(java.awt.Color colour, java.awt.Color under) { - int alpha = colour.getAlpha(); - if (alpha >= 255) { - return colour; - } - double weight = alpha / 255.0; - return new java.awt.Color( - blend(colour.getRed(), under.getRed(), weight), - blend(colour.getGreen(), under.getGreen(), weight), - blend(colour.getBlue(), under.getBlue(), weight)); - } - - private static int blend(int over, int under, double weight) { - return (int) Math.round(over * weight + under * (1 - weight)); - } - /** * A run, inside a hyperlink when the thing being written is one. * @@ -9559,17 +9648,20 @@ private void writeRule(XWPFDocument document, DocumentNode node, DocxRules.Rule pendingSpacingAfter = Math.max(0, pendingSpacingAfter - excess); } - // A border is opaque, so a translucent rule is flattened against what lies under it, as a - // chip is; one that is not drawn at all keeps its place and draws nothing. + // A border is opaque, so a translucent rule is flattened against what the page paints under + // it, as a chip is; one that is not drawn at all keeps its place and draws nothing. java.awt.Color colour = rule.colour().color(); if (colour.getAlpha() > 0) { CTPBdr borders = properties.isSetPBdr() ? properties.getPBdr() : properties.addNewPBdr(); CTBorder bottom = borders.isSetBottom() ? borders.getBottom() : borders.addNewBottom(); - java.awt.Color under = surfaceBehind != null ? surfaceBehind.color() : java.awt.Color.WHITE; paintEdge(bottom, dashOf(rule), BigInteger.valueOf(ruleEighths(rule.thickness())), - toHexColor(flatten(colour, under))); + toHexColor(colour.getAlpha() < 255 ? DocxTranslucency.flatten(colour, colourUnder(node)) : colour)); bottom.setSpace(BigInteger.ZERO); } + if (DocxTranslucency.translucent(colour)) { + report.add(DocxExportReport.Severity.APPROXIMATED, TRANSLUCENCY, layout.pathOf(node), + "the rule is flattened against the colour under it, because a paragraph border is opaque"); + } if (node instanceof com.demcha.compose.document.node.LineNode lineNode && lineNode.linkTarget() != null) { report.add(DocxExportReport.Severity.APPROXIMATED, "rule link", layout.pathOf(node), "the rule is written as a paragraph border, which carries no link"); @@ -10415,6 +10507,8 @@ private void writeTableRows(XWPFDocument document, TableNode node) throws Except // the column count. XWPFTable table = newTable(document, rowCount, 1); applyTableWidth(table, node, columnCount); + boolean translucentFills = false; + boolean translucentRules = false; for (int rowIdx = 0; rowIdx < rowCount; rowIdx++) { XWPFTableRow row = table.getRow(rowIdx); List physical = new ArrayList<>(); @@ -10432,10 +10526,21 @@ private void writeTableRows(XWPFDocument document, TableNode node) throws Except XWPFTableCell cell = row.getCell(i); applySpans(cell, placement, rowIdx); // The covered positions of a merge take the paint too, so a merged - // region reads as one cell rather than as a striped run of them. - DocumentColor fill = resolveCellFill(node, placement); + // region reads as one cell rather than as a striped run of them. A translucent + // fill or rule is flattened against what the page paints under the cell, as Word's + // shading and borders are opaque. + DocumentColor authoredFill = resolveCellFill(node, placement); DocumentStroke stroke = resolveCellStroke(node, placement); - applyCellPaint(cell, fill, stroke); + boolean translucentFill = DocxTranslucency.flattensFill(authoredFill); + boolean translucentRule = DocxTranslucency.flattensStroke(stroke); + translucentFills |= translucentFill; + translucentRules |= translucentRule; + java.awt.Color under = !(translucentFill || translucentRule) ? java.awt.Color.WHITE + : surfaceBehind != null ? surfaceBehind.color() + : layout.colourUnderCell(node, placement.row(), placement.column()).orElse(java.awt.Color.WHITE); + DocumentColor fill = DocxTranslucency.flattenedFill(authoredFill, under); + // The page draws the rules over the cells' fills, in a pass after them. + applyCellPaint(cell, fill, DocxTranslucency.flattenedStroke(stroke, fill != null ? fill.color() : under)); int next = placement.row() + placement.rowSpan(); DocumentStroke underneath = next < rowCount ? resolveCellStroke(node, cover[next][placement.column()]) : null; applyCellPadding(cell, clearOfTheRules(resolveCellPadding(node, placement), stroke, @@ -10473,6 +10578,7 @@ private void writeTableRows(XWPFDocument document, TableNode node) throws Except breakRowsWhereTheLayoutDoes(table, node); indentTable(table); carryDrawingsInRows(table, node); + reportFlattenedPaint(node, translucentFills, translucentRules, "its cells' fills", "its cells' rules"); } /** @@ -10565,9 +10671,10 @@ private void applySpans(XWPFTableCell cell, TableGrid.Placement placement, int r * {@code w:shd} for the fill and {@code w:tcBorders} for the four edges — so this is * mapping rather than approximation.

* - *

What does not survive is transparency. A {@code w:shd} fill is opaque, so a colour - * carrying an opacity below 1 lands at full strength; the alternative would be blending it - * against a background this backend does not resolve, Word owning the flow.

+ *

What does not survive is transparency: {@code w:shd} and {@code w:tcBorders} are + * opaque, so the caller hands over a translucent fill or stroke already flattened against + * what the page paints under it ({@link DocxTranslucency#flatten}) and names it in the + * report.

*/ private void applyCellPaint(XWPFTableCell cell, DocumentColor fill, DocumentStroke stroke) { if (fill == null && stroke == null) { @@ -13297,8 +13404,13 @@ private void applyRunColourAndDecoration(XWPFRun run, // colours built from the same channels are unequal unless they are the // same object, and a style built inline per paragraph would keep writing // a colour the Normal style already says. + // getRGB carries the alpha, so a run whose colour differs from Normal's only in + // strength writes its own. || style.color().color().getRGB() != defaults.color().color().getRGB())) { run.setColor(toHexColor(style.color().color())); + textFillsWritten |= DocxTranslucency.writeTextAlpha( + run.getCTR().isSetRPr() ? run.getCTR().getRPr() : run.getCTR().addNewRPr(), + style.color().color(), normalIsTranslucent()); } if (style.decoration() != null) { switch (style.decoration()) { @@ -13354,7 +13466,7 @@ private static String toHexColor(java.awt.Color color) { if (color == null) { return "000000"; } - return String.format("%02X%02X%02X", color.getRed(), color.getGreen(), color.getBlue()); + return DocxTranslucency.hex(color); } /** diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucency.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucency.java new file mode 100644 index 000000000..b23fe4c19 --- /dev/null +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucency.java @@ -0,0 +1,306 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.style.DocumentBorders; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentStroke; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFHeaderFooter; +import org.apache.xmlbeans.XmlCursor; +import org.apache.xmlbeans.XmlObject; + +import javax.xml.namespace.QName; +import java.awt.Color; +import java.util.ArrayList; +import java.util.List; + +/** + * A colour's alpha, where Word holds it and where it does not. + * + *

Text holds it. Word 2010 added a text fill to a run's properties, {@code w14:textFill}, + * whose colour carries a transparency — Word's own Font, Text Effects, Transparency — and both + * editors draw it (measured in Word 16.0.20430 and LibreOffice: red at three quarters + * transparency comes out {@code (255, 193, 193)} over white in each). The two read it apart: + * Word takes the colour from the text fill, LibreOffice takes it from {@code w:color} and the + * transparency from the text fill, so {@code w:color} keeps the colour as authored — flattened, + * LibreOffice would lighten it twice. Word sets such text as drawing when it saves a PDF, so + * its PDF of the file holds no text layer for it; the file itself holds the text.

+ * + *

A cell's shading, a border and a run's shading do not: they take an RGB and nothing else. + * A translucent fill or rule written there is flattened first against the colour under it + * ({@link #flatten}), so it shows the colour the page shows, and is no longer translucent — + * recoloured underneath in Word, it stays the colour it was flattened to. The backend names + * each one in its report.

+ */ +final class DocxTranslucency { + + /** Word 2010's namespace, which the text fill is in. */ + private static final String W14 = "http://schemas.microsoft.com/office/word/2010/wordml"; + /** Markup compatibility, which says what a reader that does not know a namespace may skip. */ + private static final String MC = "http://schemas.openxmlformats.org/markup-compatibility/2006"; + + private static final QName TEXT_FILL = new QName(W14, "textFill", "w14"); + private static final QName IGNORABLE = new QName(MC, "Ignorable", "mc"); + + private DocxTranslucency() { + } + + /** + * Composites a colour over what sits beneath it, so a translucent fill survives a format + * that has no alpha. An opaque colour is returned untouched. + * + * @param colour the colour laid on top, alpha included + * @param under the opaque colour beneath it + * @return the opaque colour the two make + */ + static Color flatten(Color colour, Color under) { + int alpha = colour.getAlpha(); + if (alpha >= 255) { + return colour; + } + double weight = alpha / 255.0; + return new Color( + blend(colour.getRed(), under.getRed(), weight), + blend(colour.getGreen(), under.getGreen(), weight), + blend(colour.getBlue(), under.getBlue(), weight)); + } + + private static int blend(int over, int under, double weight) { + return (int) Math.round(over * weight + under * (1 - weight)); + } + + /** + * Whether a colour is translucent: drawn, and not at full strength. + * + * @param colour a colour, or {@code null} + * @return true for an alpha between 1 and 254 + */ + static boolean translucent(Color colour) { + return colour != null && colour.getAlpha() > 0 && colour.getAlpha() < 255; + } + + /** + * Whether a fill is one a cell's shading holds only by flattening: translucent. + * + * @param fill a fill, or {@code null} + * @return true for a translucent one + */ + static boolean flattensFill(DocumentColor fill) { + return fill != null && translucent(fill.color()); + } + + /** + * Whether a stroke is one a border holds only by flattening: one with a width, in a colour not + * at full strength. A wholly transparent one is flattened too — into the colour under it — so + * the border keeps the room the page's rule holds in Word's row and cell geometry. + * + * @param stroke a stroke, or {@code null} + * @return true where the border is written flattened + */ + static boolean flattensStroke(DocumentStroke stroke) { + return stroke != null && stroke.width() > 0 && stroke.color() != null + && stroke.color().color().getAlpha() < 255; + } + + /** + * Whether any of a block's sides is a stroke a border holds only flattened. + * + * @param borders the sides, or {@code null} + * @return true where one is + */ + static boolean flattensSides(DocumentBorders borders) { + return borders != null && (flattensStroke(borders.top()) || flattensStroke(borders.right()) + || flattensStroke(borders.bottom()) || flattensStroke(borders.left())); + } + + /** + * A fill as a cell's shading holds it: flattened against the colour under it where it is + * translucent, and none where it is wholly transparent, as the page draws nothing. + * + * @param fill the authored fill, or {@code null} + * @param under the opaque colour under the block + * @return the fill to write, or {@code null} for none + */ + static DocumentColor flattenedFill(DocumentColor fill, Color under) { + if (fill == null || fill.color().getAlpha() == 0) { + return null; + } + return fill.color().getAlpha() >= 255 ? fill : DocumentColor.of(flatten(fill.color(), under)); + } + + /** + * A stroke as a border holds it: a colour not at full strength flattened against the colour + * the page draws it over. + * + * @param stroke the authored stroke, or {@code null} + * @param under the opaque colour under the stroke + * @return the stroke to write + */ + static DocumentStroke flattenedStroke(DocumentStroke stroke, Color under) { + if (!flattensStroke(stroke)) { + return stroke; + } + return new DocumentStroke(DocumentColor.of(flatten(stroke.color().color(), under)), stroke.width()); + } + + /** + * A block's sides as borders hold them, each flattened as {@link #flattenedStroke} does. + * + * @param borders the authored sides, or {@code null} + * @param under the opaque colour under the sides + * @return the sides to write + */ + static DocumentBorders flattenedBorders(DocumentBorders borders, Color under) { + return borders == null ? null : new DocumentBorders(flattenedStroke(borders.top(), under), + flattenedStroke(borders.right(), under), flattenedStroke(borders.bottom(), under), + flattenedStroke(borders.left(), under)); + } + + /** + * A colour as Word writes one: six hex digits, its alpha dropped. + * + * @param colour a colour + * @return {@code RRGGBB} + */ + static String hex(Color colour) { + return String.format("%02X%02X%02X", colour.getRed(), colour.getGreen(), colour.getBlue()); + } + + /** + * Writes a run's transparency as its text fill, or takes away a text fill an opaque colour + * no longer wants. + * + *

Word reads the transparency — not the opacity — as hundred-thousandths: three quarters + * transparent is {@code 75000}. The fill declares its own namespace, so a part with no + * text fill is not touched; {@link #settle} puts it last among the run's properties, + * where Word writes it, once nothing more is written on them.

+ * + *

A run follows its style's text fill where it writes none, and Word draws the fill's + * colour over the run's own: an opaque run under a translucent default would come out in the + * default's colour. Such a run writes an opaque fill of its own.

+ * + * @param properties a run's properties — a run's, a style's or a numbering level's — + * its {@code w:color} already written + * @param colour the run's colour, alpha included + * @param styleIsTranslucent whether the style the run follows carries a text fill + * @return whether a text fill was written + */ + static boolean writeTextAlpha(XmlObject properties, Color colour, boolean styleIsTranslucent) { + removeTextFill(properties); + if (colour.getAlpha() >= 255 && !styleIsTranslucent) { + return false; + } + try (XmlCursor cursor = properties.newCursor()) { + cursor.toEndToken(); + cursor.beginElement(TEXT_FILL); + cursor.beginElement(new QName(W14, "solidFill", "w14")); + cursor.beginElement(new QName(W14, "srgbClr", "w14")); + cursor.insertAttributeWithValue(new QName(W14, "val", "w14"), hex(colour)); + if (colour.getAlpha() < 255) { + long transparency = Math.round((255 - colour.getAlpha()) * 100000.0 / 255.0); + cursor.beginElement(new QName(W14, "alpha", "w14")); + cursor.insertAttributeWithValue(new QName(W14, "val", "w14"), Long.toString(transparency)); + } + } + return true; + } + + /** + * Copies a run's text fill onto other properties — a paragraph's mark, in whose style a + * list's marker is drawn where its level states none — replacing any they held. + * + * @param from the properties a text fill is read from + * @param to the properties it is written on + */ + static void copyTextFill(XmlObject from, XmlObject to) { + removeTextFill(to); + for (XmlObject fill : from.selectChildren(TEXT_FILL)) { + try (XmlCursor source = fill.newCursor(); XmlCursor target = to.newCursor()) { + target.toEndToken(); + source.copyXml(target); + } + } + } + + private static void removeTextFill(XmlObject properties) { + for (XmlObject fill : properties.selectChildren(TEXT_FILL)) { + try (XmlCursor cursor = fill.newCursor()) { + cursor.removeXml(); + } + } + } + + /** + * Settles the text fills of a finished document. Each is moved last among its properties, + * after what was written on them since — a decoration, a chip's shading, a direction — + * where Word writes it. Each part holding one marks Word 2010's namespace on its root as one a + * reader that does not know it may skip ({@code mc:Ignorable}). A part holding none is not + * touched. + * + * @param document the finished document + */ + static void settle(XWPFDocument document) { + List roots = new ArrayList<>(); + roots.add(document.getDocument()); + // The styles and numbering parts' roots as the document holds them, read from one of + // their children: XWPFDocument.getStyle() parses a copy of the part. + if (document.getStyles() != null && !document.getStyles().getStyles().isEmpty()) { + roots.add(parentOf(document.getStyles().getStyles().get(0).getCTStyle())); + } + if (document.getNumbering() != null && !document.getNumbering().getAbstractNums().isEmpty()) { + roots.add(parentOf(document.getNumbering().getAbstractNums().get(0).getCTAbstractNum())); + } + // From the document's relations: a header or footer made in this export is related to the + // document, but POI lists in getHeaderList() and getFooterList() only the parts it read. + for (org.apache.poi.ooxml.POIXMLDocumentPart part : document.getRelations()) { + if (part instanceof XWPFHeaderFooter headerOrFooter) { + roots.add(headerOrFooter._getHdrFtr()); + } + } + for (XmlObject root : roots) { + if (root != null) { + settlePart(root); + } + } + } + + private static XmlObject parentOf(XmlObject child) { + try (XmlCursor cursor = child.newCursor()) { + cursor.toParent(); + return cursor.getObject(); + } + } + + private static void settlePart(XmlObject root) { + XmlObject[] fills = root.selectPath("declare namespace w14='" + W14 + "' .//w14:textFill"); + if (fills.length == 0) { + return; + } + for (XmlObject fill : fills) { + try (XmlCursor source = fill.newCursor(); XmlCursor target = fill.newCursor()) { + target.toParent(); + target.toEndToken(); + source.moveXml(target); + } + } + String ignorable; + try (XmlCursor cursor = root.newCursor()) { + ignorable = cursor.getAttributeText(IGNORABLE); + if (ignorable != null && (" " + ignorable + " ").contains(" w14 ")) { + return; + } + // Declared first, each once, so the attribute below takes the prefixes they name. + boolean declaresW14 = W14.equals(cursor.namespaceForPrefix("w14")); + boolean declaresMc = MC.equals(cursor.namespaceForPrefix("mc")); + cursor.toNextToken(); + if (!declaresW14) { + cursor.insertNamespace("w14", W14); + } + if (!declaresMc) { + cursor.insertNamespace("mc", MC); + } + } + try (XmlCursor cursor = root.newCursor()) { + cursor.setAttributeText(IGNORABLE, ignorable == null ? "w14" : ignorable + " w14"); + } + } +} diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java index 444799e62..15f01eb01 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java @@ -88,13 +88,25 @@ private enum Fate { private record Entry(Fate fate, String note) { } + // A text colour's alpha is written, as Word's text fill, and a drawing's in DrawingML; what a + // cell's shading or a border holds is opaque, so a translucent one is flattened and reported. + private static final String PANEL_TRANSLUCENCY = "its translucency, as a panel's, flattened against the colour " + + "under it, a border's against the panel's fill where it has " + + "one; any other is written"; + private static final String RULE_TRANSLUCENCY = "its translucency, as a rule's, flattened against the colour " + + "under it; drawn, its alpha is written; any other is written"; + private static final String CELL_TRANSLUCENCY = "the translucency of a cell's fill, flattened against the colour " + + "under the cell, and of its rules, against its fill where it has " + + "one; any other is written"; + private static final Map, Map> NODES = new LinkedHashMap<>(); private static final Map OUTPUT_OPTIONS = fields( "metadata:WRITTEN", "watermark:REPORTED", "protection:REPORTED", "viewerPreferences:REPORTED", - "headersAndFooters:WRITTEN", + "headersAndFooters:REPORTED:a band's translucent separator, flattened against white; any other is " + + "written, or reported where Word's parts cannot hold it", // The node entries below are the body's. A zone is written as one line of its // paragraphs' runs, page fields and tabs; what of that the report does not name is // a gap of its own. @@ -119,7 +131,8 @@ private record Entry(Fate fate, String note) { "margin:REPORTED:in the chart's note; above and below; below a block a band or a column measures from, written", "padding:REPORTED:in the chart's note; above and below; below a block a band or a column measures from, written"); node(ContainerNode.class, "name:INERT", "children:WRITTEN", "spacing:WRITTEN", "padding:WRITTEN", - "margin:WRITTEN", "fillColor:WRITTEN", "stroke:WRITTEN", "cornerRadius:REPORTED", "borders:WRITTEN", + "margin:WRITTEN", "fillColor:REPORTED:" + PANEL_TRANSLUCENCY, "stroke:REPORTED:" + PANEL_TRANSLUCENCY, + "cornerRadius:REPORTED", "borders:REPORTED:" + PANEL_TRANSLUCENCY, "anchor:REPORTED:as a layer stack's column, which has no bookmark; in the flow it is bookmarked", "bookmarkOptions:REPORTED", "flowWidth:REPORTED:of an unpainted one, a panel in a table cell and a layer stack's column"); node(EllipseNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "fillColor:WRITTEN", @@ -134,16 +147,18 @@ private record Entry(Fate fate, String note) { node(LayerStackNode.class, "name:INERT", "layers:WRITTEN", "padding:WRITTEN", "margin:WRITTEN", "clipToBounds:REPORTED:where it cuts what its layers paint; composed in a table cell, on its table"); node(LineNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "startX:WRITTEN", "startY:WRITTEN", - "endX:WRITTEN", "endY:WRITTEN", "stroke:WRITTEN", + "endX:WRITTEN", "endY:WRITTEN", "stroke:REPORTED:" + RULE_TRANSLUCENCY, "linkTarget:REPORTED", "bookmarkOptions:REPORTED", "padding:WRITTEN", "margin:WRITTEN", "transform:REPORTED", "dashPattern:REPORTED", "anchor:REPORTED:drawn; a rule in the flow is bookmarked", "lineCap:REPORTED:a cap other than butt; whether an editor ends a drawn butt line flat is not measured", "fillWidth:WRITTEN", "keepWithNext:REPORTED:of a line drawn in the flow the page keeps blocks together in"); node(ListNode.class, "name:INERT", - "items:REPORTED:a blank one a hangingIndent list draws as its marker alone, and the marks of one " - + "the page reads as markdown; any other is written", - "nestedItems:REPORTED:the marks of one the page reads as markdown; any other is written", + "items:REPORTED:a blank one a hangingIndent list draws as its marker alone, the marks of one " + + "the page reads as markdown, and a chip's translucent fill, flattened against the colour under " + + "it; any other is written", + "nestedItems:REPORTED:the marks of one the page reads as markdown, and a chip's translucent fill, " + + "flattened against the colour under it; any other is written", "marker:WRITTEN", "textStyle:WRITTEN", "align:REPORTED", "lineSpacing:REPORTED:where the layout's items are not its own and one wraps, an item run " @@ -175,8 +190,9 @@ private record Entry(Fate fate, String note) { node(ParagraphNode.class, "name:INERT", "text:REPORTED:where the page reads it as markdown, its marks written as letters; where its " + "lines are not read and the page's parser drops a mark, not measured; any other is written", - "inlineRuns:WRITTEN", "textStyle:WRITTEN", - "align:WRITTEN", "lineSpacing:WRITTEN", + "inlineRuns:REPORTED:a chip's translucent fill, flattened against the colour under it, with what " + + "else of its shape Word cannot hold; a run's translucent colour is written as Word's text fill", + "textStyle:WRITTEN", "align:WRITTEN", "lineSpacing:WRITTEN", "bulletOffset:REPORTED:its letters before the first line; over the flow, as a side of an " + "overlay's pair or as a badge's text, the room it sets lines in by where that moves one", "indentStrategy:WRITTEN", "linkTarget:WRITTEN", @@ -204,7 +220,8 @@ private record Entry(Fate fate, String note) { node(com.demcha.compose.document.layout.HorizontalBandContentNode.class, "name:INERT", "key:INERT", "slot:WRITTEN", "child:WRITTEN"); node(SectionNode.class, "name:INERT", "children:WRITTEN", "spacing:WRITTEN", "padding:WRITTEN", - "margin:WRITTEN", "fillColor:WRITTEN", "stroke:WRITTEN", "cornerRadius:REPORTED", "borders:WRITTEN", + "margin:WRITTEN", "fillColor:REPORTED:" + PANEL_TRANSLUCENCY, "stroke:REPORTED:" + PANEL_TRANSLUCENCY, + "cornerRadius:REPORTED", "borders:REPORTED:" + PANEL_TRANSLUCENCY, "keepTogether:WRITTEN", "anchor:REPORTED:as a layer stack's column, which has no bookmark; in the flow it is bookmarked", "bleed:REPORTED:of a panel the page bleeds, in the flow it pages", @@ -212,9 +229,13 @@ private record Entry(Fate fate, String note) { "flowWidth:REPORTED:of an unpainted one, a panel in a table cell and a layer stack's column"); node(ShapeContainerNode.class, "name:INERT", "outline:WRITTEN", "layers:WRITTEN", "clipPolicy:REPORTED:where it cuts what its layers paint; composed in a table cell, on its table", - "fillColor:WRITTEN", "stroke:WRITTEN", "padding:WRITTEN", "margin:WRITTEN", + "fillColor:REPORTED:its translucency, as a panel's, flattened against the colour under it; " + + "drawn, its alpha is written; any other is written", + "stroke:REPORTED:its translucency, as a panel's border, flattened against the panel's fill or " + + "the colour under it; drawn, its alpha is written; any other is written", + "padding:WRITTEN", "margin:WRITTEN", "transform:REPORTED"); - node(ShapeNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "fillColor:WRITTEN", + node(ShapeNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "fillColor:REPORTED:" + RULE_TRANSLUCENCY, "stroke:WRITTEN", "cornerRadius:REPORTED:unequal corners, drawn at the largest radius", "linkTarget:REPORTED", "bookmarkOptions:REPORTED", "padding:WRITTEN", "margin:WRITTEN", "transform:REPORTED", "fillPaint:REPORTED", @@ -223,8 +244,9 @@ private record Entry(Fate fate, String note) { "padding:REPORTED:above and below; below a block a band or a column measures from, written", "margin:REPORTED:above and below; below a block a band or a column measures from, written", "grow:WRITTEN"); - node(TableNode.class, "name:INERT", "columns:WRITTEN", "rows:WRITTEN", "defaultCellStyle:WRITTEN", - "rowStyles:WRITTEN", "columnStyles:WRITTEN", "width:WRITTEN", "linkTarget:REPORTED", + node(TableNode.class, "name:INERT", "columns:WRITTEN", "rows:REPORTED:" + CELL_TRANSLUCENCY, + "defaultCellStyle:REPORTED:" + CELL_TRANSLUCENCY, "rowStyles:REPORTED:" + CELL_TRANSLUCENCY, + "columnStyles:REPORTED:" + CELL_TRANSLUCENCY, "width:WRITTEN", "linkTarget:REPORTED", "bookmarkOptions:REPORTED", "padding:WRITTEN", "margin:WRITTEN", "repeatedHeaderRowCount:WRITTEN", "anchor:WRITTEN"); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucencyTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucencyTest.java new file mode 100644 index 000000000..8916ba6c6 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucencyTest.java @@ -0,0 +1,628 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.api.PageBackgroundFill; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.output.DocumentHeaderFooter; +import com.demcha.compose.document.output.DocumentHeaderFooterZone; +import com.demcha.compose.document.style.DocumentBorders; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; +import com.demcha.compose.document.table.DocumentTableStyle; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFRun; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.apache.xmlbeans.XmlObject; +import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBorder; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr; + +import javax.xml.namespace.QName; +import java.io.ByteArrayInputStream; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A translucent colour in the Word file: text carries its transparency as Word's text fill, and + * a cell's shading, a border and a rule — which hold only an opaque colour — are flattened + * against the colour the page paints under them and named in the report. + * + *

The colour under is the layout's, so a fill over a page's background composites over that + * background, not over white. The expected values are worked out by hand: a channel is + * {@code round(over × a + under × (1 − a))}, {@code a} the alpha over 255.

+ */ +class DocxTranslucencyTest { + + private static final String W14 = "http://schemas.microsoft.com/office/word/2010/wordml"; + private static final DocumentColor NAVY = DocumentColor.rgb(28, 39, 64); + private static final DocumentColor HALF_BLUE = DocumentColor.rgba(0, 90, 200, 128); + private static final String FILL_NOTE = "its fill is flattened against the colour under it, because a Word " + + "cell's shading and borders are opaque"; + + @Test + void translucentTextIsWrittenWithItsTransparency() throws Exception { + try (Exported exported = export(null, page -> page + .addParagraph(p -> p.text("Faint") + .textStyle(DocumentTextStyle.DEFAULT.withColor(DocumentColor.rgba(200, 0, 0, 64)))) + .addParagraph(p -> p.text("Solid words, long enough to be the body of this page") + .textStyle(DocumentTextStyle.DEFAULT.withColor(DocumentColor.rgb(0, 0, 200)))))) { + CTRPr faint = run(exported.document(), "Faint").getCTR().getRPr(); + // LibreOffice takes the colour from w:color and the transparency from the text fill, so + // w:color keeps the colour as authored. + assertThat(hex(faint.getColorArray(0).getVal())).isEqualTo("C80000"); + // Transparency, not opacity: (255 - 64) / 255 = 74.902%. + assertThat(textFill(faint)).isEqualTo("C80000@74902"); + assertThat(textFill(run(exported.document(), "Solid").getCTR().getRPr())) + .as("an opaque colour writes no text fill").isNull(); + assertThat(exported.report().bySubject()).as("written, not reported") + .doesNotContainKey("translucency"); + } + } + + @Test + void aTranslucentBodyColourIsTheStylesAndAnOpaqueRunOverridesIt() throws Exception { + DocumentTextStyle faint = DocumentTextStyle.DEFAULT.withColor(DocumentColor.rgba(0, 0, 0, 153)); + try (Exported exported = export(null, page -> page + .addParagraph(p -> p.text("Heading").textStyle(DocumentTextStyle.DEFAULT.withSize(16))) + .addParagraph(p -> p.text("The body of the page is set in a faint black, and there is " + + "more of it than of the heading, so it is the style's.").textStyle(faint)) + .addParagraph(p -> p.text("A second paragraph in the same faint black.").textStyle(faint)) + .addList(list -> list.name("Points").textStyle(DocumentTextStyle.DEFAULT + .withColor(DocumentColor.rgb(0, 0, 0))).items("One", "Two")))) { + CTRPr marker = exported.document().getNumbering().getAbstractNums().get(0).getCTAbstractNum() + .getLvlArray(0).getRPr(); + assertThat(textFill(marker)).as("an opaque list's marker over the translucent style").isEqualTo("000000@"); + CTRPr defaults = exported.document().getStyles().getStyle("Normal").getCTStyle().getRPr(); + assertThat(textFill(defaults)).as("the Normal style's text fill").isEqualTo("000000@40000"); + assertThat(textFill(run(exported.document(), "The body").getCTR().getRPr())) + .as("a run in the style's colour follows the style").isNull(); + // A run follows the style's text fill where it writes none, and Word draws the fill's + // colour over the run's: the opaque heading would come out faint. + assertThat(textFill(run(exported.document(), "Heading").getCTR().getRPr())) + .as("an opaque run under a translucent style writes an opaque fill").isEqualTo("000000@"); + } + } + + @Test + void aPanelsTranslucentFillIsFlattenedAgainstThePagesBackground() throws Exception { + try (Exported exported = export(navyPage(), page -> page + .addSection("Card", card -> card.fillColor(HALF_BLUE).addParagraph("Inside")))) { + // 0/90/200 at 128/255 over 28/39/64. + assertThat(shading(onlyCell(exported.document()))).isEqualTo("0E4184"); + assertThat(notes(exported)).containsExactly(FILL_NOTE); + } + try (Exported exported = export(null, page -> page + .addSection("Card", card -> card.fillColor(HALF_BLUE).addParagraph("Inside")))) { + assertThat(shading(onlyCell(exported.document()))).as("over the page's white").isEqualTo("7FACE3"); + } + } + + @Test + void aChipOnAPanelThatWasFlattenedCompositesOverWhatWasWritten() throws Exception { + try (Exported exported = export(navyPage(), page -> page + .addSection("Card", card -> card.fillColor(HALF_BLUE) + .addParagraph(p -> p.inlineText("Call ").inlineCode("render()"))))) { + String card = shading(onlyCell(exported.document())); + assertThat(card).isEqualTo("0E4184"); + // 175/184/193 at 51/255 over the card as written, 14/65/132: what Word paints under it. + // The chip's last letter is a run of its own, which carries the space after it. + assertThat(runShading(run(exported.document(), "render("))).isEqualTo("2E5990"); + } + } + + @Test + void aChipOnAPagesBackgroundCompositesOverThatBackground() throws Exception { + try (Exported exported = export(navyPage(), page -> page + .addParagraph(p -> p.inlineText("Call ").inlineCode("render()")))) { + // 175/184/193 at 51/255 over 28/39/64 — over white it was 239/241/243. + assertThat(runShading(run(exported.document(), "render("))).isEqualTo("39445A"); + } + } + + @Test + void aPanelsTranslucentBordersAreFlattenedAndNamed() throws Exception { + try (Exported exported = export(null, page -> page + .addSection("Card", card -> card.borders(DocumentBorders.all( + DocumentStroke.of(DocumentColor.rgba(200, 0, 0, 100), 2))) + .addParagraph("Inside")))) { + CTBorder top = onlyCell(exported.document()).getCTTc().getTcPr().getTcBorders().getTop(); + // 200/0/0 at 100/255 over white. + assertThat(hex(top.getColor())).isEqualTo("E99B9B"); + assertThat(notes(exported)).containsExactly("its borders are flattened against the colour under them, " + + "because a Word cell's shading and borders are opaque"); + } + } + + @Test + void aWhollyTransparentFillIsNoShadingAndABorderKeepsItsRoomInTheColourUnder() throws Exception { + try (Exported exported = export(navyPage(), page -> page + .addSection("Card", card -> card.fillColor(DocumentColor.rgba(0, 90, 200, 0)) + .borders(DocumentBorders.all(DocumentStroke.of(DocumentColor.rgba(200, 0, 0, 0), 2))) + .addParagraph("Inside")))) { + XWPFTableCell cell = onlyCell(exported.document()); + assertThat(shading(cell)).as("the page draws no fill").isNull(); + assertThat(hex(cell.getCTTc().getTcPr().getTcBorders().getTop().getColor())) + .as("the border holds its room, in the navy under it").isEqualTo("1C2740"); + assertThat(notes(exported)).containsExactly("its borders are flattened against the colour under them, " + + "because a Word cell's shading and borders are opaque"); + } + } + + @Test + void aTablesTranslucentCellsAreFlattenedAndNamedOnce() throws Exception { + DocumentTableStyle tint = DocumentTableStyle.builder().fillColor(HALF_BLUE) + .stroke(DocumentStroke.of(DocumentColor.rgba(200, 0, 0, 100), 1)).build(); + try (Exported exported = export(navyPage(), page -> page.add(new com.demcha.compose.document.dsl.TableBuilder() + .name("Rota").columns(DocumentTableColumn.fixed(100), DocumentTableColumn.fixed(100)) + .rowCells(DocumentTableCell.text("A").withStyle(tint), DocumentTableCell.text("B").withStyle(tint)) + .rowCells(DocumentTableCell.text("C").withStyle(tint), DocumentTableCell.text("D").withStyle(tint)) + .build()))) { + XWPFTableCell cell = exported.document().getTables().get(0).getRow(1).getCell(1); + assertThat(shading(cell)).isEqualTo("0E4184"); + // The page draws the rules over the cells' fills: 200/0/0 at 100/255 over 14/65/132. + assertThat(hex(cell.getCTTc().getTcPr().getTcBorders().getTop().getColor())).isEqualTo("572850"); + assertThat(notes(exported)).containsExactly("its cells' fills and its cells' rules are flattened against " + + "the colour under them, because a Word cell's shading and " + + "borders are opaque"); + } + } + + @Test + void aTranslucentRuleIsFlattenedAgainstThePagesBackground() throws Exception { + try (Exported exported = export(navyPage(), page -> page + .addDivider(d -> d.width(200).thickness(1).color(DocumentColor.rgba(255, 255, 255, 115))))) { + CTBorder bottom = ruleParagraph(exported.document()).getCTP().getPPr().getPBdr().getBottom(); + // White at 115/255 over 28/39/64: a pale navy, as the page shows it, and not white. + assertThat(hex(bottom.getColor())).isEqualTo("828896"); + assertThat(notes(exported)).containsExactly( + "the rule is flattened against the colour under it, because a paragraph border is opaque"); + } + } + + @Test + void aTranslucentSeparatorIsFlattenedAgainstWhite() throws Exception { + try (Exported exported = export(session -> session.header(DocumentHeaderFooter.builder() + .zone(DocumentHeaderFooterZone.HEADER).height(30).fontSize(10).leftText("Header") + .showSeparator(true).separatorColor(DocumentColor.rgba(200, 0, 0, 100)).separatorThickness(1) + .build()), page -> page.addParagraph("Body"))) { + XWPFParagraph band = exported.document().getHeaderList().get(0).getParagraphs().get(0); + assertThat(hex(band.getCTP().getPPr().getPBdr().getBottom().getColor())).isEqualTo("E99B9B"); + assertThat(notes(exported)).containsExactly("a page header's separator is flattened against white, " + + "because a paragraph border is opaque"); + } + } + + @Test + void aPanelOverATableCellIsFlattenedAgainstTheCell() throws Exception { + // The table is the stack's back layer and the panel stands over its cell, which the page + // paints first: what is under the panel is the cell's red, not the page. + DocumentTableStyle red = DocumentTableStyle.builder().fillColor(DocumentColor.rgb(200, 40, 40)).build(); + try (Exported exported = export(null, page -> page.addLayerStack(stack -> stack.name("Stack") + .back(new com.demcha.compose.document.dsl.TableBuilder().name("Under") + .columns(DocumentTableColumn.fixed(200)) + .rowCells(DocumentTableCell.text("Under the panel").withStyle(red)).build()) + .center(new com.demcha.compose.document.dsl.SectionBuilder().name("Over").fillColor(HALF_BLUE) + .addParagraph("Over").build())))) { + // 0/90/200 at 128/255 over 200/40/40. + assertThat(allCells(exported.document())).extracting(DocxTranslucencyTest::shading).contains("644178"); + } + } + + @Test + void withNoLayoutAChipInARowOnATranslucentPanelCompositesOverThePanelAsWritten() throws Exception { + // The row's cell has no shading, and nothing tells the colour under the paragraph but the + // panel the export set it on — the panel as written, not its translucent colour. + try (XWPFDocument document = DocxExports.withoutLayout(400, 400, 20, page -> page + .addSection("Card", card -> card.fillColor(HALF_BLUE) + .addRow(row -> row.addParagraph(p -> p.inlineText("Call ").inlineCode("render()")) + .addParagraph(p -> p.text("Beside")))))) { + assertThat(shading(document.getTables().get(0).getRow(0).getCell(0))).isEqualTo("7FACE3"); + // 175/184/193 at 51/255 over 127/172/227. + assertThat(runShading(run(document, "render("))).isEqualTo("89AEDC"); + } + } + + @Test + void aPanelsBordersAreFlattenedAgainstItsOwnFill() throws Exception { + // The page draws the borders over the panel's fill, not over what is under the panel. + try (Exported exported = export(null, page -> page + .addSection("Card", card -> card.fillColor(NAVY).borders(DocumentBorders.all( + DocumentStroke.of(DocumentColor.rgba(255, 255, 255, 51), 2))) + .addParagraph("Inside")))) { + CTBorder top = onlyCell(exported.document()).getCTTc().getTcPr().getTcBorders().getTop(); + // White at 51/255 over 28/39/64; over the white page it would be white. + assertThat(hex(top.getColor())).isEqualTo("495266"); + } + } + + @Test + void aRowsOwnFillIsNotWhatIsUnderItsChip() throws Exception { + // The export does not write a row's own fill — the report names it as row paint — so what + // Word shows under the chip is the page's white, and the chip is flattened against that. + try (Exported exported = export(null, page -> page.addRow(row -> row.fillColor(NAVY) + .addParagraph(p -> p.inlineText("Call ").inlineCode("render()")).addParagraph("Beside")))) { + assertThat(exported.report().bySubject()).as("the row's fill is not written").containsKey("row paint"); + assertThat(runShading(run(exported.document(), "render("))).isEqualTo("EFF1F3"); + } + } + + @Test + void aSeparatorWrittenIntoSeveralPartsIsNamedOnce() throws Exception { + // A footer kept off the first page gives the section a first page of its own, so the header + // band is written into the first page's part and the default one. + try (Exported exported = export(session -> session.header(DocumentHeaderFooter.builder() + .zone(DocumentHeaderFooterZone.HEADER).height(30).fontSize(10).leftText("Header") + .showSeparator(true).separatorColor(DocumentColor.rgba(200, 0, 0, 100)) + .separatorThickness(1).build()) + .footer(DocumentHeaderFooter.builder().zone(DocumentHeaderFooterZone.FOOTER).height(20) + .fontSize(8).rightText("{page}").numbering(com.demcha.compose.document.output + .DocumentPageNumbering.builder().showOnFirstPage(false).build()).build()), + page -> page.addParagraph("Body"))) { + assertThat(exported.document().getHeaderList()).as("written into more than one part").hasSizeGreaterThan(1); + assertThat(notes(exported)).containsExactly("a page header's separator is flattened against white, " + + "because a paragraph border is opaque"); + } + } + + @Test + void aTextFillIsTheLastOfARunsPropertiesAndItsPartSaysItMayBeSkipped() throws Exception { + DocumentTextStyle faint = new DocumentTextStyle(DocumentTextStyle.DEFAULT.fontName(), 10, + com.demcha.compose.document.style.DocumentTextDecoration.UNDERLINE, DocumentColor.rgba(200, 0, 0, 64)); + try (Exported exported = export(null, page -> page + .addParagraph(p -> p.text("Underlined").textStyle(faint)) + .addParagraph(p -> p.text("The body of the page, in an opaque colour and longer than the rest")))) { + CTRPr properties = run(exported.document(), "Underlined").getCTR().getRPr(); + assertThat(properties.sizeOfUArray()).as("the underline is written").isEqualTo(1); + // Word writes its 2010 run properties after the ones Word 2007 had. + try (org.apache.xmlbeans.XmlCursor cursor = properties.newCursor()) { + cursor.toLastChild(); + assertThat(cursor.getName()).isEqualTo(new QName(W14, "textFill")); + } + QName ignorable = new QName("http://schemas.openxmlformats.org/markup-compatibility/2006", "Ignorable"); + try (org.apache.xmlbeans.XmlCursor root = exported.document().getDocument().newCursor()) { + assertThat(root.getAttributeText(ignorable)).as("the body part").isEqualTo("w14"); + } + try (org.apache.xmlbeans.XmlCursor root = exported.document().getStyle().newCursor()) { + assertThat(root.getAttributeText(ignorable)).as("the styles part holds no text fill").isNull(); + } + } + } + + @Test + void anOpaqueDocumentWritesNothingOfWord2010() throws Exception { + try (Exported exported = export(session -> session.header(DocumentHeaderFooter.builder() + .zone(DocumentHeaderFooterZone.HEADER).height(30).fontSize(10).leftText("Header").build()), + page -> page.addParagraph("Body").addList(list -> list.name("Points").items("One", "Two")) + .addSection("Card", card -> card.fillColor(NAVY).addParagraph("Inside")))) { + try (java.util.zip.ZipInputStream zip = new java.util.zip.ZipInputStream( + new ByteArrayInputStream(exported.bytes()))) { + for (java.util.zip.ZipEntry entry; (entry = zip.getNextEntry()) != null; ) { + if (entry.getName().endsWith(".xml")) { + assertThat(new String(zip.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)) + .as(entry.getName()).doesNotContain("wordprocessingml/2010").doesNotContain("Ignorable"); + } + } + } + } + } + + @Test + void aNestedItemsMarkTakesTheTextFillItsMarkerIsDrawnIn() throws Exception { + DocumentTextStyle faint = DocumentTextStyle.DEFAULT.withColor(DocumentColor.rgba(0, 0, 0, 153)); + try (Exported exported = export(null, page -> page + .addParagraph(p -> p.text("The body of the page is set in a faint black, and there is more of it " + + "than of the list, so it is the style's.").textStyle(faint)) + .addList(list -> list.name("Points").textStyle(DocumentTextStyle.DEFAULT + .withColor(DocumentColor.rgb(0, 0, 0))) + .addItem("Languages", child -> child.addItem("Java").addItem("Kotlin"))))) { + XWPFParagraph nested = (XWPFParagraph) run(exported.document(), "Java").getParent(); + // A nested level states no style of its own, so its marker is drawn in the mark's. + assertThat(textFill(nested.getCTP().getPPr().getRPr())).as("an opaque fill over the faint style") + .isEqualTo("000000@"); + } + } + + @Test + void aContainersTranslucentFillAndALinesStrokeAreFlattenedAndNamed() throws Exception { + try (Exported exported = export(null, page -> page + .add(new com.demcha.compose.document.node.ContainerNode("Box", + List.of(new com.demcha.compose.document.dsl.ParagraphBuilder().name("Inside").text("Inside") + .build()), 0, DocumentInsets.of(4), DocumentInsets.zero(), HALF_BLUE, null)) + .addLine(line -> line.horizontal(200).stroke(DocumentStroke.of(DocumentColor.rgba(0, 0, 0, 128), 1))))) { + assertThat(shading(onlyCell(exported.document()))).isEqualTo("7FACE3"); + // Black at 128/255 over white. + assertThat(hex(ruleParagraph(exported.document()).getCTP().getPPr().getPBdr().getBottom().getColor())) + .isEqualTo("7F7F7F"); + assertThat(notes(exported)).containsExactly(FILL_NOTE, + "the rule is flattened against the colour under it, because a paragraph border is opaque"); + } + } + + @Test + void underAPictureTheSurfaceStandsInAndTheFillIsStillNamed() throws Exception { + // The layout carries no colour for a picture's pixels: the panel over it is flattened against + // the surface it is written on — the page's white — and named all the same. + try (Exported exported = export(navyPage(), page -> page.addLayerStack(stack -> stack.name("Stack") + .back(new com.demcha.compose.document.dsl.ImageBuilder().name("Photo") + .source(com.demcha.compose.document.image.DocumentImageData.fromBytes(png())).size(200, 80) + .build()) + .center(new com.demcha.compose.document.dsl.SectionBuilder().name("Over").fillColor(HALF_BLUE) + .addParagraph("Over").build())))) { + assertThat(allCells(exported.document())).extracting(DocxTranslucencyTest::shading).contains("7FACE3"); + assertThat(notes(exported)).contains(FILL_NOTE); + } + } + + @Test + void everyPartHoldingATextFillEndsItsPropertiesWithItAndMarksItsNamespaceIgnorable() throws Exception { + // A translucent body colour is the Normal style's, so every opaque run anywhere — a header's + // and a footer's text, a page number, a table cell's — writes an opaque fill of its own. + DocumentTextStyle faint = DocumentTextStyle.DEFAULT.withColor(DocumentColor.rgba(0, 0, 0, 153)); + DocumentTableStyle tinted = DocumentTableStyle.builder().textStyle(new DocumentTextStyle( + DocumentTextStyle.DEFAULT.fontName(), 10, + com.demcha.compose.document.style.DocumentTextDecoration.UNDERLINE, DocumentColor.rgba(200, 0, 0, 64))) + .build(); + try (Exported exported = export(session -> session.header(DocumentHeaderFooter.builder() + .zone(DocumentHeaderFooterZone.HEADER).height(30).fontSize(10).leftText("Header").build()) + .footer(DocumentHeaderFooter.builder().zone(DocumentHeaderFooterZone.FOOTER).height(20).fontSize(8) + .rightText("Page {page}").build()), + page -> page.addParagraph(p -> p.text("The body of the page is set in a faint black, and there is " + + "more of it than of anything else.").textStyle(faint)) + .add(new com.demcha.compose.document.dsl.TableBuilder().name("Rota") + .columns(DocumentTableColumn.fixed(200)) + .rowCells(DocumentTableCell.text("Underlined in the cell").withStyle(tinted)).build()))) { + assertThat(textFill(run(exported.document(), "Underlined in the cell").getCTR().getRPr())) + .as("a table cell's run").isEqualTo("C80000@74902"); + java.util.Map parts = xmlParts(exported.bytes()); + assertThat(parts.keySet()).as("the header and the footer hold fills") + .anyMatch(name -> name.startsWith("word/header") && parts.get(name).contains("w14:textFill")) + .anyMatch(name -> name.startsWith("word/footer") && parts.get(name).contains("w14:textFill")); + parts.forEach((name, xml) -> { + int fills = count(xml, " 0) { + String root = xml.substring(xml.indexOf('<', xml.indexOf("?>") + 2), xml.indexOf('>', xml.indexOf("?>") + 2)); + assertThat(root).as(name + "'s root").contains("mc:Ignorable=\"w14\""); + assertThat(count(xml, "")).as(name + ": each fill last of its properties") + .isEqualTo(fills); + } + }); + } + } + + @Test + void aPanelSplitByAPageBreakIsOneColourAndNamedOnce() throws Exception { + try (Exported exported = export(navyPage(), page -> page.addSection("Card", card -> card.fillColor(HALF_BLUE) + .addParagraph("First").addPageBreak(pageBreak -> { }).addParagraph("Second")))) { + assertThat(exported.document().getTables()).as("a table a piece").hasSize(2) + .allSatisfy(table -> assertThat(shading(table.getRow(0).getCell(0))).isEqualTo("0E4184")); + assertThat(notes(exported)).containsExactly(FILL_NOTE); + } + } + + @Test + void aMergedTranslucentCellIsFlattenedAsOne() throws Exception { + DocumentTableStyle tint = DocumentTableStyle.builder().fillColor(HALF_BLUE).build(); + try (Exported exported = export(navyPage(), page -> page.add(new com.demcha.compose.document.dsl.TableBuilder() + .name("Rota").columns(DocumentTableColumn.fixed(100), DocumentTableColumn.fixed(100)) + .rowCells(DocumentTableCell.text("A").withStyle(tint).rowSpan(2), DocumentTableCell.text("B")) + .rowCells(DocumentTableCell.text("C")) + .build()))) { + var table = exported.document().getTables().get(0); + assertThat(shading(table.getRow(0).getCell(0))).isEqualTo("0E4184"); + assertThat(shading(table.getRow(1).getCell(0))).as("the merge's covered row").isEqualTo("0E4184"); + assertThat(notes(exported)).containsExactly("its cells' fills are flattened against the colour under " + + "them, because a Word cell's shading and borders are opaque"); + } + } + + @Test + void aRuleInAFilledRowIsFlattenedAgainstWhatWordShows() throws Exception { + // The export does not write a row's own fill, so what Word shows under the rule is the page. + try (Exported exported = export(null, page -> page.addRow(row -> row.fillColor(NAVY) + .addLine(line -> line.horizontal(100).stroke(DocumentStroke.of(DocumentColor.rgba(0, 0, 0, 128), 1))) + .addParagraph("Beside")))) { + CTBorder bottom = allParagraphs(exported.document()).stream() + .filter(p -> p.getCTP().getPPr() != null && p.getCTP().getPPr().isSetPBdr()) + .findFirst().orElseThrow().getCTP().getPPr().getPBdr().getBottom(); + // Black at 128/255 over white, not over the row's navy. + assertThat(hex(bottom.getColor())).isEqualTo("7F7F7F"); + } + } + + @Test + void contentInAFlattenedPanelIsSetOnThePanelAsWritten() throws Exception { + // The panel stands over the page's navy column and its white side, and is flattened at its + // centre, over the navy: a chip in its right half composites over the panel Word paints there, + // not over the white the page has under the chip. + try (Exported exported = export(session -> session.pageBackgrounds(List.of( + PageBackgroundFill.leftColumn(0.6, NAVY))), + page -> page.addSection("Card", card -> card.fillColor(HALF_BLUE) + .addRow(row -> row.addParagraph("Left") + .addParagraph(p -> p.inlineText("Call ").inlineCode("render()"))) + .addRow(row -> row.addParagraph("Left").addLine(line -> line.horizontal(100) + .stroke(DocumentStroke.of(DocumentColor.rgba(0, 0, 0, 128), 1)))) + // A table in the panel's right half, over the page's white side. + .add(new com.demcha.compose.document.dsl.TableBuilder().name("Tint") + .columns(DocumentTableColumn.fixed(80)).margin(new DocumentInsets(0, 0, 0, 260)) + .rowCells(DocumentTableCell.text("Cell").withStyle(DocumentTableStyle.builder() + .fillColor(HALF_BLUE).build())).build())))) { + XWPFTableCell tinted = allCells(exported.document()).stream() + .filter(cell -> cell.getText().contains("Cell") && cell.getTables().isEmpty()) + .findFirst().orElseThrow(); + // 0/90/200 at 128/255 over 14/65/132. + assertThat(shading(tinted)).as("a translucent cell in the panel").isEqualTo("074EA6"); + assertThat(shading(allCells(exported.document()).get(0))).isEqualTo("0E4184"); + // 175/184/193 at 51/255 over 14/65/132. + assertThat(runShading(run(exported.document(), "render("))).isEqualTo("2E5990"); + CTBorder rule = allParagraphs(exported.document()).stream() + .filter(p -> p.getCTP().getPPr() != null && p.getCTP().getPPr().isSetPBdr()) + .findFirst().orElseThrow().getCTP().getPPr().getPBdr().getBottom(); + // Black at 128/255 over 14/65/132, not over the panel-on-white the page has under the rule. + assertThat(hex(rule.getColor())).isEqualTo("072042"); + } + } + + @Test + void withNoLayoutATranslucentFillIsStillNamed() throws Exception { + DocxExportReport report = DocxExports.reportWithoutLayout(400, 400, 20, page -> page + .addSection("Card", card -> card.fillColor(HALF_BLUE).addParagraph("Inside"))); + assertThat(report.bySubject().get("translucency")).extracting(DocxExportReport.Note::detail) + .containsExactly(FILL_NOTE); + } + + private static Consumer navyPage() { + return session -> session.pageBackgrounds(List.of(PageBackgroundFill.fullPage(NAVY))); + } + + private record Exported(XWPFDocument document, DocxExportReport report, byte[] bytes) implements AutoCloseable { + @Override + public void close() throws Exception { + document.close(); + } + } + + /** The package's XML parts, by name. */ + private static java.util.Map xmlParts(byte[] docx) throws Exception { + java.util.Map parts = new java.util.TreeMap<>(); + try (java.util.zip.ZipInputStream zip = new java.util.zip.ZipInputStream(new ByteArrayInputStream(docx))) { + for (java.util.zip.ZipEntry entry; (entry = zip.getNextEntry()) != null; ) { + if (entry.getName().endsWith(".xml")) { + parts.put(entry.getName(), new String(zip.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)); + } + } + } + return parts; + } + + private static int count(String text, String of) { + int count = 0; + for (int at = text.indexOf(of); at >= 0; at = text.indexOf(of, at + of.length())) { + count++; + } + return count; + } + + private static byte[] png() { + try (java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) { + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(40, 40, + java.awt.image.BufferedImage.TYPE_INT_RGB), "png", out); + return out.toByteArray(); + } catch (Exception failure) { + throw new IllegalStateException(failure); + } + } + + private static Exported export(Consumer setup, Consumer content) throws Exception { + AtomicReference captured = new AtomicReference<>(); + byte[] bytes; + try (DocumentSession session = GraphCompose.document().pageSize(400, 400).margin(DocumentInsets.of(20)) + .create()) { + if (setup != null) { + setup.accept(session); + } + session.pageFlow(content::accept); + bytes = session.export(new DocxSemanticBackend(captured::set)); + } + return new Exported(new XWPFDocument(new ByteArrayInputStream(bytes)), captured.get(), bytes); + } + + private static List notes(Exported exported) { + return exported.report().bySubject().getOrDefault("translucency", List.of()).stream() + .map(DocxExportReport.Note::detail).toList(); + } + + private static XWPFRun run(XWPFDocument document, String startsWith) { + for (XWPFParagraph paragraph : allParagraphs(document)) { + for (XWPFRun run : paragraph.getRuns()) { + if (run.text().startsWith(startsWith)) { + return run; + } + } + } + throw new AssertionError("no run starts with " + startsWith); + } + + private static List allParagraphs(XWPFDocument document) { + List paragraphs = new java.util.ArrayList<>(document.getParagraphs()); + allCells(document).forEach(cell -> paragraphs.addAll(cell.getParagraphs())); + return paragraphs; + } + + /** Every cell, nested tables' included: a row in a panel is a table in the panel's cell. */ + private static List allCells(XWPFDocument document) { + List cells = new java.util.ArrayList<>(); + document.getTables().forEach(table -> collectCells(table, cells)); + return cells; + } + + private static void collectCells(org.apache.poi.xwpf.usermodel.XWPFTable table, List cells) { + table.getRows().forEach(row -> row.getTableCells().forEach(cell -> { + cells.add(cell); + cell.getTables().forEach(nested -> collectCells(nested, cells)); + })); + } + + private static XWPFParagraph ruleParagraph(XWPFDocument document) { + return document.getParagraphs().stream() + .filter(p -> p.getCTP().getPPr() != null && p.getCTP().getPPr().isSetPBdr()) + .findFirst().orElseThrow(); + } + + private static XWPFTableCell onlyCell(XWPFDocument document) { + assertThat(document.getTables()).hasSize(1); + return document.getTables().get(0).getRow(0).getCell(0); + } + + private static String shading(XWPFTableCell cell) { + var properties = cell.getCTTc().getTcPr(); + return properties == null || !properties.isSetShd() ? null : hex(properties.getShd().getFill()); + } + + private static String runShading(XWPFRun run) { + CTRPr properties = run.getCTR().getRPr(); + return properties.sizeOfShdArray() == 0 ? null : hex(properties.getShdArray(0).getFill()); + } + + /** A text fill as {@code RRGGBB@transparency}, the transparency empty for an opaque one; null for none. */ + private static String textFill(XmlObject properties) { + if (properties == null) { + return null; + } + XmlObject[] fills = properties.selectChildren(new QName(W14, "textFill")); + if (fills.length == 0) { + return null; + } + assertThat(fills).as("one text fill").hasSize(1); + XmlObject colour = fills[0].selectChildren(new QName(W14, "solidFill"))[0] + .selectChildren(new QName(W14, "srgbClr"))[0]; + XmlObject[] alpha = colour.selectChildren(new QName(W14, "alpha")); + return attribute(colour) + "@" + (alpha.length == 0 ? "" : attribute(alpha[0])); + } + + private static String attribute(XmlObject element) { + try (org.apache.xmlbeans.XmlCursor cursor = element.selectAttribute(new QName(W14, "val")).newCursor()) { + return cursor.getTextValue(); + } + } + + /** XmlBeans hands an ST_HexColor back as bytes, so read it as the colour it encodes. */ + private static String hex(Object value) { + if (value instanceof byte[] bytes) { + StringBuilder text = new StringBuilder(); + for (byte part : bytes) { + text.append(String.format("%02X", part & 0xFF)); + } + return text.toString(); + } + return String.valueOf(value); + } +}