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 `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.OptionalA {@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 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.
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) { + ListThe 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