From 58ba89767946f5466e443c719da2108037d079a4 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 21 Oct 2026 17:06:13 +0100 Subject: [PATCH] fix(docx): name inline picture losses and write a %N marker as text Word stands an inline picture on the baseline, and the export moves it by w:position to where the page sets it, from the layout's measure of the paragraph's first line. Where that measure does not hold, the report said nothing: - a picture beside text in a paragraph whose lines are not read stands on the baseline, where the page sets it by its alignment or offset; - a picture on a later line of another height is set by the first line's measure; - one Word paragraph takes one seat by its cap band (verticalAlign), its first text line's, where the page seats each line by its own; - a shape's picture is wider than its box, and where no text beside it gives that back - a shape opening its paragraph or following a picture or a line break, one before pictures alone, one in a list item or a right-to-left line - it moves what stands in its line, from the side the line's alignment sets it; - a chip padded below zero, which Word's shading does not follow; - an icon's gradient, which the raster draws flat in its first colour. Each is now named on the paragraph's, the shape's, the chip's or the icon's note, from half a point, but a picture set by its alignment in lines that are not read, where no line says how far. A list marker holding a percent sign before a digit (%1, Step %2:) was written as a Word list level, and Word reads %1 to %9 in a level's text as that level's number: measured, neither marker showed in Word. Such a list is now written as paragraphs with its markers as text. None of the corpus's 62 DOCX changes, and none of its 1246 notes. --- CHANGELOG.md | 23 ++ .../architecture/backend-capability-matrix.md | 12 +- docs/recipes/docx-export.md | 34 +- .../semantic/docx/DocxSemanticBackend.java | 270 ++++++++++++- .../semantic/docx/DocxShapePictures.java | 11 + .../docx/DocxInlinePictureLossTest.java | 380 ++++++++++++++++++ 6 files changed, 707 insertions(+), 23 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlinePictureLossTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 57e145403..ff94681b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export names what a picture among a paragraph's words loses of its place, its width + or its paint.** Word stands an inline picture on the baseline, and the export moves it by + `w:position` to where the page sets it, from the layout's measure of the paragraph's first + line. + - The paragraph's note names a picture beside text where the paragraph's lines are not read, + which nothing moves off the baseline; a picture on a later line of another height, set by + the first line's measure; and the lines of a paragraph seated by its cap band + (`verticalAlign`) in another size or face than its first, since one Word paragraph takes + one seat. + - A shape's note names how far its picture, wider than its box, moves what stands in its line + where no text beside it gives that back — a shape opening its paragraph or following a + picture or a line break, one before pictures alone, one in a list item or a right-to-left + line — reckoned from the side the line's alignment sets it from. + - A chip's note names a padding below zero on any side, which Word's shading does not follow; + an icon's names a gradient, which the raster draws flat, in its first colour. + - Each is named from half a point, but a picture the page sets by its alignment in lines that + are not read, where no line says how far. + - None of the corpus's 62 DOCX changes, and none of its 1246 notes. +- **A DOCX export writes a list marker Word would read as a level's number as text.** A marker + holding a percent sign before a digit — `%1 `, `Step %2: ` — was written as a Word list level, + and Word reads `%1` to `%9` in a level's text as that level's number: measured, neither marker + showed in Word. Such a list is now written as paragraphs with its markers as text, as a list + whose siblings carry different markers is. None of the corpus's 62 DOCX changes. - **A DOCX export names a block that writes no paragraph for its anchor's bookmark.** A bookmark goes round the paragraphs a block writes, and a section or a container of drawings alone writes none: its drawings stand in the paragraph beside them. A link to its anchor is written all the diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 28b8de6de..a666cbc2c 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -66,12 +66,12 @@ Payload records live in `core` under | Capability (payload) | PDF (fixed) | PPTX (fixed) | DOCX (semantic) | |---|---|---|---| -| 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 character the page draws nothing for (Unicode category C but the direction marks and joiners) is not written; a margin below zero sets a paragraph's, a list's or a block's text out past that side in the flow as the page does, in a table cell no further out than the cell's text, what is held back reported; 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, matched at a paragraph's middle line: a line of its own Word sets a tenth of a point or more low is moved up into the space written above it and that space owed below it — a line pair's shared line by the lesser of its halves' calls where neither is moved by its position — in the flow, in cells, in a stack of one layer, which is the flow, and in a band, which takes back the raise of its last line before its next layer and below itself, not in another overlay nor a band's first paragraph or item where a band in the flow opens its page, a paragraph the layout moves to a new page or breaks over one, nor the space a panel's top border takes above its first line; a line opening a cell, under half a point low, takes what that space cannot give from the cell's top padding — a panel's, its row held as much taller, and a table's composed cell's in a row whose margins even — and a row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, what follows kept in place (not a row the layout moves to a new page, held to a height, or whose column opens with a panel or a table); a paragraph in a composed table cell of a placed row is raised as anywhere, its row kept on one page; a line of its own Word sets a tenth of a point or more, and under half a point, high is lowered by what is left of the space between it and what follows once the block under it has taken its own of it — its line gap, its raise, its pictures' room, a row's lift out of the space above its table — above that block, or written below the line before a table or at a cell's end, nothing under it moved (not where the next block is on a later page or in an overlay, nor at a page break, a block moved to the next page or a column's later layer, and where the section's end leaves a cell's space below out, the lowering it gave is taken back with it; not a line cut to fit, opening its page or a panel, not on one page, or in an overlay other than a stack of one layer, nor a list's item, lowered by its own list); what that space cannot give, and a line Word sets half a point or more high, is moved in the same position where the two are half a point or more apart — Spectral's, not Lato's — which LibreOffice moves further by the face's height over its em (a list's item seated as a paragraph is — not raised into the space above where the layout opens a page with it or sets its list over two pages in a cell, an item whose marker Word draws not moved by its position, since LibreOffice does not move that marker by its mark's, but lowered, where Word sets it high, by the space above it, as far as the items under it and what follows the list give that back, and taken back where the hang below the list is let go, and one the layout's lines are not matched to not seated (a list composed in a table cell is matched to the run of its table's lines holding its items as the page lays them out, opening at a line no node before it took, and takes their height, lineSpacing, `hangingIndent` marker column and seat as one in the flow does, an item in a row Word keeps whole rising into the gap above it — a Word list whose marker stands in its text keeping its stated column, having no place of its own; one holding an item of runs, an item whose markdown marks the page drops, a flagged list's blank item or an item run onto the next page is not matched); a table text cell's by its row's margins, see Table rows; 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 that takes its style is written at the size the page fits it to, read off the laid-out lines, a run with a style of its own keeping it, on every path that writes a paragraph — at its style's size where the lines do not tell the fitted size (no lines read, lines in sizes that do not say which is the paragraph's, or one paragraph added at more than one place); a paragraph a session reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, read through the page's own parser, one run a piece in the face, family, colour, tracking and size the page's laid-out lines hold (an auto-sized one's read at the size the page fits it to, a heading at its multiple of it), its marks dropped, wherever the lines hold the pieces' letters so; where they are not read, or hold other letters or none (text the parser reads into nothing, which the page sets as nothing), as authored, its marks as letters; 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 an auto-sized paragraph's text is written at, where the size the page fits it to is not measured; the marks of a paragraph the page read as markdown and the file holds as letters, where its laid-out lines hold fewer of them than its text, not measured where its lines are not read; a markdown heading written taller than the line the page sets it in, which Word cuts on screen; 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 that its table's lines are not matched to, 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 what items the page reads as markdown lose. An item of plain text the page reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, matched to the lines the page laid it out in and read as the page lays it out (a flat item after the marker the page sets before it; a flagged item as its text alone; an item of a list built as a tree of items without the flag after the indent and marker the page reads with it), one run a piece in the face, family, colour, tracking and size those lines hold, Word or the file drawing the marker where it did (Word draws a tree's bullets regular, as the parser sets the marker it reads), the font table shipping the faces the page sets the pieces in; it is written as authored, its marks as letters, where its list's items are not matched one by one to the layout's (no layout, composed in a table cell and not matched to its table's lines, an item run onto the next page, a flagged list with a blank item), where its lines hold other letters, where the parser reads a tree's marker as markdown with the item (`*a*`), and where the parser reads it into nothing, and named where the page drops a mark from it or sets none of its text — not where it changes only its face or letters no mark is made of (`1.`); a markdown heading written taller than its item's line is named | -| 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; Word could hold a shape in the line, `wp:inline` round a `wps:wsp`, which the export does not write, so each shape among the words is reported `APPROXIMATED` as a picture) | -| 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; every icon is reported `APPROXIMATED` as a picture, and an icon's text as its description) | +| 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 character the page draws nothing for (Unicode category C but the direction marks and joiners) is not written; a margin below zero sets a paragraph's, a list's or a block's text out past that side in the flow as the page does, in a table cell no further out than the cell's text, what is held back reported; 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, matched at a paragraph's middle line: a line of its own Word sets a tenth of a point or more low is moved up into the space written above it and that space owed below it — a line pair's shared line by the lesser of its halves' calls where neither is moved by its position — in the flow, in cells, in a stack of one layer, which is the flow, and in a band, which takes back the raise of its last line before its next layer and below itself, not in another overlay nor a band's first paragraph or item where a band in the flow opens its page, a paragraph the layout moves to a new page or breaks over one, nor the space a panel's top border takes above its first line; a line opening a cell, under half a point low, takes what that space cannot give from the cell's top padding — a panel's, its row held as much taller, and a table's composed cell's in a row whose margins even — and a row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, what follows kept in place (not a row the layout moves to a new page, held to a height, or whose column opens with a panel or a table); a paragraph in a composed table cell of a placed row is raised as anywhere, its row kept on one page; a line of its own Word sets a tenth of a point or more, and under half a point, high is lowered by what is left of the space between it and what follows once the block under it has taken its own of it — its line gap, its raise, its pictures' room, a row's lift out of the space above its table — above that block, or written below the line before a table or at a cell's end, nothing under it moved (not where the next block is on a later page or in an overlay, nor at a page break, a block moved to the next page or a column's later layer, and where the section's end leaves a cell's space below out, the lowering it gave is taken back with it; not a line cut to fit, opening its page or a panel, not on one page, or in an overlay other than a stack of one layer, nor a list's item, lowered by its own list); what that space cannot give, and a line Word sets half a point or more high, is moved in the same position where the two are half a point or more apart — Spectral's, not Lato's — which LibreOffice moves further by the face's height over its em (a list's item seated as a paragraph is — not raised into the space above where the layout opens a page with it or sets its list over two pages in a cell, an item whose marker Word draws not moved by its position, since LibreOffice does not move that marker by its mark's, but lowered, where Word sets it high, by the space above it, as far as the items under it and what follows the list give that back, and taken back where the hang below the list is let go, and one the layout's lines are not matched to not seated (a list composed in a table cell is matched to the run of its table's lines holding its items as the page lays them out, opening at a line no node before it took, and takes their height, lineSpacing, `hangingIndent` marker column and seat as one in the flow does, an item in a row Word keeps whole rising into the gap above it — a Word list whose marker stands in its text keeping its stated column, having no place of its own; one holding an item of runs, an item whose markdown marks the page drops, a flagged list's blank item or an item run onto the next page is not matched); a table text cell's by its row's margins, see Table rows; 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 that takes its style is written at the size the page fits it to, read off the laid-out lines, a run with a style of its own keeping it, on every path that writes a paragraph — at its style's size where the lines do not tell the fitted size (no lines read, lines in sizes that do not say which is the paragraph's, or one paragraph added at more than one place); a paragraph a session reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, read through the page's own parser, one run a piece in the face, family, colour, tracking and size the page's laid-out lines hold (an auto-sized one's read at the size the page fits it to, a heading at its multiple of it), its marks dropped, wherever the lines hold the pieces' letters so; where they are not read, or hold other letters or none (text the parser reads into nothing, which the page sets as nothing), as authored, its marks as letters; 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 an auto-sized paragraph's text is written at, where the size the page fits it to is not measured; the marks of a paragraph the page read as markdown and the file holds as letters, where its laid-out lines hold fewer of them than its text, not measured where its lines are not read; a markdown heading written taller than the line the page sets it in, which Word cuts on screen; the lines of a paragraph seated off its baseline that take another seat than its first, from half a point; a picture on a later line, set by the first line's measure, from half a point; pictures beside text standing on the baseline where the 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, a drawn marker or a marker holding a percent sign before a digit, which Word reads in a level's text as a level's number, 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 that its table's lines are not matched to, 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 what items the page reads as markdown lose. An item of plain text the page reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, matched to the lines the page laid it out in and read as the page lays it out (a flat item after the marker the page sets before it; a flagged item as its text alone; an item of a list built as a tree of items without the flag after the indent and marker the page reads with it), one run a piece in the face, family, colour, tracking and size those lines hold, Word or the file drawing the marker where it did (Word draws a tree's bullets regular, as the parser sets the marker it reads), the font table shipping the faces the page sets the pieces in; it is written as authored, its marks as letters, where its list's items are not matched one by one to the layout's (no layout, composed in a table cell and not matched to its table's lines, an item run onto the next page, a flagged list with a blank item), where its lines hold other letters, where the parser reads a tree's marker as markdown with the item (`*a*`), and where the parser reads it into nothing, and named where the page drops a mark from it or sets none of its text — not where it changes only its face or letters no mark is made of (`1.`); a markdown heading written taller than its item's line is named | +| 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, the padding above and below the letters and a padding below zero on any side 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; a picture on a later line of another height is set by that measure too, and where the paragraph's lines are not read, nothing moves a picture beside text off the baseline: a paragraph's note names either, not a list item's; 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, which the text beside it gives back in whole tenths of a point a letter where it has letters on one line (`giveBackWidth`) — where none does, the shape's note says, from half a point, up to how far that moves it and what stands beside it in its line, as Word sets the line from the side its alignment names, the paragraph taken as one line; what the tenths miss, up to a twentieth of a point a letter and all of it in a run too long for a tenth a letter, is not named, a quarter point at most on the corpus; 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; Word could hold a shape in the line, `wp:inline` round a `wps:wsp`, which the export does not write, so each shape among the words is reported `APPROXIMATED` as a picture) | +| 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; every icon is reported `APPROXIMATED` as a picture, an icon's text as its description, and a gradient, which the raster draws flat in its first colour) | | 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, and so is a `RowNode` in the flow that paints its box (`writePaintedRow`, through the same `writePanelPiece`), its columns a table nested in the cell without its padding — not in a band of layers or a stack's measured column, with a padding or margin it cannot hold, composed in a table cell or in a page zone, where it is its columns alone and its paint is reported: 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). Word sets a cell's content its whole top border and its margin inside the row, and ends the row its margin and its whole bottom border past the content, so half of each border — and as much of its inner half as the padding falls short of it — reaches past the panel: the space round it takes that first, then its padding, the border then drawn that much inside the panel's edge, then, above, the room over its first line; what none of them takes is reported (`space above` on the block below, the panel's kind for its top border). What its content leaves below itself past where the page ends it — a last line held to an icon, a card's border ending it, which that card's padding takes first — comes out of its padding below first, the same way; a card's border ending a row's cell reaches past the row only by what the row's room under that cell does not hold, and one ending a table's composed cell is reported on the card, its row up to that much taller. A negative bottom margin pulls what follows up out of the space between them, and what that space cannot give is reported, as is the tenth of a point of the paragraph between any two tables where the space between them does not take it. A shape container's borders stay inside its outline. A panel the layout measures holds the page's height less its padding as its row's height, which Word and LibreOffice on Windows read as its content's, its margins and borders round it; a shape container holds its outline's, less its margins and its borders as wide as Word draws them, two and a half twips an eighth of a point rounded down. A panel's borders are written from two to ninety-six eighths of a point, as a table cell's rules and a rule are, and a width that, rounded to the eighth, is thinner or wider is named (`border width`). 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 left out, what stands on it being written in the row's panel, on its shading). 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, the outline mitred at its corners as the page strokes it, 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 | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index e19a0719d..aa913f9f0 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -415,7 +415,7 @@ Nesting is a level rather than padding, so no indent characters reach the text, `ListMarker.defaultForDepth` cascade the PDF path uses becomes the levels' markers — `markerFor(depth, ...)` still chooses a level's own. -Four kinds of list stay plain paragraphs, because Word could not express them without +Five kinds of list stay plain paragraphs, because Word could not express them without changing what was asked for: - **A markerless list.** Numbering always draws something and indents; a list that asked @@ -430,6 +430,9 @@ changing what was asked for: names one marker per level, and silently replacing one of them with the other would be worse than writing both as text. - **Rich items**, whose runs the numbered path does not write. +- **A marker holding a percent sign before a digit** — `%1 `, `Step %2: `. Word reads `%1` + to `%9` in a level's text as that level's number: written as a level, neither marker showed + in Word (measured). The marker column of a list's top level is the page's when the layout set the marker in a column of its own — a `hangingIndent(true)` list — and the gap after it covers what Word may @@ -635,6 +638,12 @@ page.addParagraph(p -> p on a line of 14pt text — keeps `w:position`, and in LibreOffice stands on the baseline, higher than on the page by as much as the page lowers it: 1.5 to 3pt for those. An author's own picture is written as given and always moved by `w:position`. + Every picture of a paragraph is moved by its first line's measure, so one on a later line + of another height stands apart from where the page sets it on its own; and where the + paragraph's lines are not read — with no layout, for one — nothing moves a picture beside + text, and it stands on the baseline. The paragraph's report note names either: one the page + sets by its alignment where no line says how far, and otherwise from half a point. A list + item's pictures are not named so. - **Line height.** Lines are written at an exact height, and the editor clips a picture to it — where in that height it puts the baseline is its own, so no fixed room is enough: measured in LibreOffice, a 14pt icon centred over 9pt text lost its top up to @@ -673,9 +682,11 @@ page.addParagraph(p -> p icon stands up to its drop too high there, its top cut, where its text is in place. - **What an icon is.** An SVG icon — an emoji among them — is drawn into a transparent picture from the same layers the page draws, by the raster the PPTX export falls back - to, so it looks as it does on the page. The text it stands for is the picture's + to, so it looks as it does on the page, but for a gradient, which that raster draws flat, + in its first colour. The text it stands for is the picture's description, which a screen reader reads; it is not a character a reader copies or - searches. The report names every icon a picture, and an icon's text its description. + searches. The report names every icon a picture, an icon's text its description, and a + gradient drawn flat. - **Links.** A picture carrying a link, or in a linked paragraph, is inside the link. - **What a shape is.** An inline shape — a `dot(...)`, an arrow, a chevron, a checkbox, @@ -687,7 +698,19 @@ page.addParagraph(p -> p far as that ink reaches there, and a pixel of the raster (a quarter point, at its four pixels a point), and is lowered by what it takes below, so the outline stands where the page has it, no edge is cut, and a shape takes that much more - room in the line than on the page. A list marker that draws a disc is written the same + room in the line than on the page. The text beside it gives that room back, its letters + set closer in whole tenths of a point, where it has letters on one line. Where none does — a + shape opening its paragraph or following a picture or a line break, the right of one before + pictures alone, a shape in a list item or a right-to-left line — the shape's report note + says, from half a point, up to how far that moves it and what stands beside it in its line. + Word sets the line from the side its alignment names: from the left, the room on a shape's + left moves it, and the room on either side what stands right of it; from the right, the + other way about; centred, each by half. The paragraph is taken as one line, so a shape the + page sets at the start or the end of a later line is reckoned as one among its words. Nor is + what the tenths miss named: rounded a letter at a time, they come to as much as a twentieth + of a point a letter more or less than the room, and to none of it in a run too long for a + tenth a letter — a quarter point at most on the corpus's 62 documents. A list + marker that draws a disc is written the same way, followed by the space a text marker gets. Word could hold a shape in the line, which this export does not write: the report names each shape among the words a picture. @@ -726,6 +749,9 @@ What Word has no way to say is the chip's *shape*. Shading covers the glyph box, One ending a centred line should be off by half that, though only the right-aligned case is measured. - **The padding above and below** the letters is not in the file. +- **A padding below zero**, on any side, is not in the file either. The page draws the fill + inside the letters' box there, and moves it up or down by padding above and below that sum + to nothing; Word shades the letters' box whatever the padding is. What is lost is recorded as `APPROXIMATED` in the export report, per chip, so a caller can see which phrase lost what — down to whether its left padding is unshaded space, in another 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 248097978..72e6dae12 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 @@ -5377,7 +5377,10 @@ private long prefixColumn(com.demcha.compose.document.node.ListNode list) { * same depth carry different markers has no definition to be given and keeps writing * its markers as text. So does a list with a drawn marker, which has no Word analogue * at all, one with no marker, where numbering would add an indent the author did not - * ask for, and one with rich items, whose runs the numbered path does not write.

+ * ask for, and one with rich items, whose runs the numbered path does not write. And one + * whose marker holds a percent sign before a digit, which Word reads in a level's text as + * a level's number: written as a level, "%1 " and "Step %2: " showed nothing in Word, and + * "%" and "S" in LibreOffice (measured).

*/ private static List markerPerDepth(com.demcha.compose.document.node.ListNode list) { java.util.Map perDepth = new java.util.TreeMap<>(); @@ -5406,9 +5409,15 @@ private static List markerPerDepth(com.demcha.compose.document.node.List return null; } } + if (perDepth.values().stream().anyMatch(text -> LEVEL_NUMBER.matcher(text).find())) { + return null; + } return List.copyOf(perDepth.values()); } + /** What Word reads in a level's text as a level's number: a percent sign before a digit. */ + private static final java.util.regex.Pattern LEVEL_NUMBER = java.util.regex.Pattern.compile("%[1-9]"); + private static boolean collectMarkers(com.demcha.compose.document.node.ListItem item, int depth, java.util.Map perDepth) { @@ -6712,7 +6721,13 @@ private PictureReach writeInlineTextRuns(XWPFParagraph para, DocumentTextStyle s InlineRun run = runs.get(index); InlineTextRun text = textOf(run); if (text == null) { - pictures = pictures.max(writeInlinePicture(para, run, null, path, line)); + // An item's text gives back none of a shape's width (giveBackWidth is a paragraph's), + // and its line is written from the left. + pictures = pictures.max(writeInlinePicture(para, run, null, path, line, + run instanceof InlineShapeRun shape + ? widthNotGivenBack(shape, false, false, index > 0, index + 1 < runs.size(), false, + TextAlign.LEFT) + : 0)); continue; } // A list item's run can carry a link, and it used to be written as plain text: @@ -10634,8 +10649,9 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) { * What a paragraph's own fields lose on the way to Word, on any path that writes it in the * body: the size an auto-sized paragraph's text is fitted to where its lines do not tell it, * the marks of a paragraph the page - * reads as markdown, its {@code bulletOffset} and its outline entry. A page zone's paragraphs - * are written apart, and named on the zone's note ({@link #zoneParagraphLost}). + * reads as markdown, its {@code bulletOffset}, its outline entry, its pictures' place in their + * lines ({@link #picturesLost}) and its lines' seat ({@link #seatLost}). A page zone's + * paragraphs are written apart, and named on the zone's note ({@link #zoneParagraphLost}). * *

Its text is written at the size the page fits it to where the layout tells it * ({@link #writtenStyle}), at its style's where not ({@link #autoSizeLost}), and in the pieces @@ -10675,6 +10691,14 @@ private List paragraphLost(ParagraphNode node, boolean roomLost, String if (roomLost) { lost.add("the room its bulletOffset sets its lines in by is not written"); } + String pictures = picturesLost(node); + if (pictures != null) { + lost.add(pictures); + } + String seat = seatLost(node); + if (seat != null) { + lost.add(seat); + } DocumentBookmarkOptions outline = node.bookmarkOptions(); if (outline != null) { if (listedAs == null) { @@ -10693,6 +10717,133 @@ private List paragraphLost(ParagraphNode node, boolean roomLost, String return lost; } + /** + * What a paragraph's pictures lose of their place in their lines, or {@code null}. + * + *

A picture stands on the baseline in Word, and is moved by {@code w:position} to where the + * page's alignment puts it, from the line the layout measured ({@link #writeInlinePicture}): + * where the paragraph's lines are not read, none is moved. One the page sets by its alignment + * is then named, the line that would measure how far being unread; one it sets on the baseline, + * from half a point off it ({@link #offTheBaseline}). All of them are moved by the first line's + * measure, so a picture on a later line of another height is set apart from where the page + * sets it; the distance is the two measures' at Word's half point, and named from half a + * point.

+ */ + private String picturesLost(ParagraphNode node) { + List lines = layout.lines(node); + if (lines.isEmpty()) { + // A line of pictures alone is set at their size, in Word as on the page: there is no + // text for them to stand off. + boolean besideText = node.inlineRuns().stream() + .anyMatch(run -> textOf(run) != null && !textOf(run).text().isBlank()); + if (!besideText) { + return null; + } + double apart = 0; + for (InlineRun run : node.inlineRuns()) { + double off = offTheBaseline(run); + if (Double.isNaN(off)) { + return "its pictures stand on the line's baseline, where the page sets them by their alignment: " + + "its lines are not read here"; + } + apart = Math.max(apart, Math.abs(off)); + } + return apart >= HALF_A_POINT + ? "its pictures stand on the line's baseline, up to " + pointsOf(apart) + + "pt from where the page sets them: its lines are not read here" + : null; + } + com.demcha.compose.document.layout.payloads.ParagraphLine first = lines.get(0); + long apart = 0; + for (int index = 1; index < lines.size(); index++) { + com.demcha.compose.document.layout.payloads.ParagraphLine line = lines.get(index); + for (com.demcha.compose.document.layout.payloads.ParagraphSpan span : line.spans()) { + InlineImageAlignment alignment; + double offset; + double height; + if (span instanceof com.demcha.compose.document.layout.payloads.ParagraphImageSpan image) { + alignment = image.alignment(); + offset = image.baselineOffset(); + height = image.height(); + } else if (span instanceof com.demcha.compose.document.layout.payloads.ParagraphShapeSpan shape) { + alignment = shape.alignment(); + offset = shape.baselineOffset(); + height = shape.height(); + } else if (span instanceof com.demcha.compose.document.layout.payloads.ParagraphSvgSpan svg) { + alignment = svg.alignment(); + offset = svg.baselineOffset(); + height = svg.height(); + } else { + continue; + } + long own = Math.round(inlineBottomFromBaseline(alignment, offset, height, line) * 2); + long asWritten = Math.round(inlineBottomFromBaseline(alignment, offset, height, first) * 2); + apart = Math.max(apart, Math.abs(own - asWritten)); + } + } + return apart > 0 + ? "a picture on a line after its first is set by the first line's measure, up to " + + pointsOf(apart / 2.0) + "pt from where the page sets it" + : null; + } + + /** + * How far the page sets an inline picture's bottom from where Word stands it, on its line's + * baseline, in points, positive up, where no line is read: by its offset, less what a shape's + * picture reaches below its box, for one set on the baseline; {@code NaN} for one set by + * another alignment, which the line would measure; 0 for a run that is no picture. + */ + private static double offTheBaseline(InlineRun run) { + InlineImageAlignment alignment; + double offset; + double below = 0; + if (run instanceof InlineImageRun image) { + alignment = image.alignment(); + offset = image.baselineOffset(); + } else if (run instanceof InlineShapeRun shape) { + alignment = shape.alignment(); + offset = shape.baselineOffset(); + below = DocxShapePictures.reachBelowItsBox(shape); + } else if (run instanceof InlineSvgRun svg) { + alignment = svg.alignment(); + offset = svg.baselineOffset(); + } else { + return 0; + } + return alignment == InlineImageAlignment.BASELINE ? offset - below : Double.NaN; + } + + /** + * What a paragraph seated by its cap band ({@code verticalAlign}) loses where its lines differ, + * or {@code null}: one Word paragraph takes one seat, its first text line's + * ({@link #seatShift}), and the page seats each line by its own, from the size and face of its + * text. The distance is the seats' at Word's half point, named from half a point. + */ + private String seatLost(ParagraphNode node) { + if (node.verticalAlign() == null || node.verticalAlign() == TextVerticalAlign.DEFAULT) { + return null; + } + Long first = null; + long apart = 0; + for (com.demcha.compose.document.layout.payloads.ParagraphLine line : layout.lines(node)) { + if (line.spans().stream().noneMatch( + span -> span instanceof com.demcha.compose.document.layout.payloads.ParagraphTextSpan)) { + continue; + } + long seat = Math.round(com.demcha.compose.document.backend.fixed.pdf.handlers.ParagraphSeating + .shift(line, measuredFonts(), node.verticalAlign()) * 2); + if (first == null) { + first = seat; + } else { + apart = Math.max(apart, Math.abs(seat - first)); + } + } + return apart > 0 + ? "its lines are seated as its first is, a line in another size or face up to " + + pointsOf(apart / 2.0) + "pt from where the page seats it" + : null; + } + /** * Whether a paragraph set in a box of its own, by its alignment, loses the room its prefix * sets a line in by: where the page lays out a prefix, unless the paragraph is one line set @@ -11986,7 +12137,18 @@ private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node, boolean InlineRun run = inlineRuns.get(index); InlineTextRun text = textOf(run); if (text == null) { - PictureReach reach = writeInlinePicture(para, run, node.linkTarget(), path, line); + // The text just before a shape gives back its width on the left, and the next text + // after it, however many pictures on, its width on the right, where their letters + // can be set closer. + double notGivenBack = 0; + if (run instanceof InlineShapeRun shape) { + InlineTextRun next = inlineRuns.subList(index + 1, inlineRuns.size()).stream() + .map(after -> textOf(after)).filter(java.util.Objects::nonNull).findFirst().orElse(null); + notGivenBack = widthNotGivenBack(shape, textBefore != null && givesBack(lettersOf.get(textBefore)), + next != null && givesBack(next.text()), index > 0, index + 1 < inlineRuns.size(), + rightToLeft, node.align()); + } + PictureReach reach = writeInlinePicture(para, run, node.linkTarget(), path, line, notGivenBack); if (reach != null) { wroteARun = true; pictures = pictures.max(reach); @@ -12633,7 +12795,7 @@ private double raiseOwedByTheLastLine() { * @param points the room the shapes beside the run take past their boxes, in points */ private static void giveBackWidth(XWPFRun run, String text, double points) { - if (text == null || text.isEmpty() || LINE_BREAK.matcher(text).find()) { + if (!givesBack(text)) { return; } long tenths = Math.round(points * 10 / text.codePointCount(0, text.length())); @@ -12642,6 +12804,14 @@ private static void giveBackWidth(XWPFRun run, String text, double points) { } } + /** + * Whether a run's letters can be set closer by the room a shape beside them takes + * ({@link #giveBackWidth}): there are some, on one line. + */ + private static boolean givesBack(String letters) { + return letters != null && !letters.isEmpty() && !LINE_BREAK.matcher(letters).find(); + } + /** * The room the page leaves after a text run's letters for a chip's padding, in points: the * run's own right padding where it is a chip, and the left padding of a chip written next. @@ -12902,21 +13072,85 @@ private double holdPicturesInTheLine(XWPFParagraph para, PictureReach pictures, /** * Names an icon or a shape among the words a picture: the text an icon stands for is its * description, and the rest is pixels. An author's own picture is what it was given. + * + *

An icon's gradient is drawn flat, in its first colour, by the raster that draws it + * ({@link com.demcha.compose.document.backend.fixed.pdf.handlers.InlineSvgRasters}); the page + * draws the gradient. A shape's picture is wider than its box by as far as its ink and a + * pixel reach past it, which the text beside it gives back ({@link #giveBackWidth}); what + * no text gives back moves what stands in its line ({@link #widthNotGivenBack}), and is named + * from half a point.

+ * + * @param widthNotGivenBack how far a shape's picture, wider than its box, moves what stands in + * its line with no text beside it to give that back, in points */ - private void reportInlinePicture(InlineRun run, String description, String path) { + private void reportInlinePicture(InlineRun run, String description, String path, double widthNotGivenBack) { + String gradient = run instanceof InlineSvgRun svg && paintsAGradient(svg.icon()) + ? "; its gradients are drawn flat, in their first colour" : ""; if (description != null && !description.isBlank()) { report.add(DocxExportReport.Severity.APPROXIMATED, "inline icon", path, "drawn as a picture, as on the page; the text it stands for (" + description - + ") is the picture's description rather than a character in the line"); + + ") is the picture's description rather than a character in the line" + gradient); } else if (run instanceof InlineSvgRun) { report.add(DocxExportReport.Severity.APPROXIMATED, "inline icon", path, - "drawn as a picture, as on the page: Word holds its pixels, not the vector drawing"); + "drawn as a picture, as on the page: Word holds its pixels, not the vector drawing" + gradient); } else if (run instanceof InlineShapeRun) { report.add(DocxExportReport.Severity.APPROXIMATED, "inline shape", path, - "drawn as a picture, as on the page: Word holds its pixels, not a shape it can edit"); + "drawn as a picture, as on the page: Word holds its pixels, not a shape it can edit" + + (widthNotGivenBack >= HALF_A_POINT + ? "; its picture is wider than its box where no text beside it gives that back, and moves " + + "it and what stands beside it in its line up to " + pointsOf(widthNotGivenBack) + + "pt from where the page sets them" + : "")); } } + /** + * The least a picture's place may be off before the report names it, in points: Word moves a + * run by half points, and less than one is not seen. + */ + private static final double HALF_A_POINT = 0.5; + + /** + * Whether an icon fills or strokes a layer with a gradient rather than one colour. An icon's + * reader gives a layer a stroke paint only with the stroke it draws. + */ + private static boolean paintsAGradient(com.demcha.compose.document.svg.SvgIcon icon) { + for (com.demcha.compose.document.svg.SvgIcon.Layer layer : icon.layers()) { + if (layer.fillPaint() != null + && !(layer.fillPaint() instanceof com.demcha.compose.document.style.DocumentPaint.Solid) + || layer.strokePaint() != null + && !(layer.strokePaint() instanceof com.demcha.compose.document.style.DocumentPaint.Solid)) { + return true; + } + } + return false; + } + + /** + * How far a shape's picture, wider than its box, moves what stands in its line where no text + * beside it gives that back, in points. In a line set left to right, the text just before the + * shape gives back the room on its left and the next text after it the room on its right, + * where their letters can be set closer ({@link #givesBack}); text set right to left gives back + * nothing. Word sets the line from the side its alignment names: from the left, the room on + * the left moves the shape and what stands right of it, and the room on the right what stands + * right of it, where anything does; from the right, the other way about; centred, each by half, + * what stands on either side by both. The paragraph is taken as one line. + */ + private static double widthNotGivenBack(InlineShapeRun shape, boolean textJustBefore, boolean textAfter, + boolean anythingBefore, boolean anythingAfter, boolean rightToLeft, + TextAlign align) { + double left = rightToLeft || !textJustBefore ? DocxShapePictures.widthBeforeItsBox(shape) : 0; + double right = rightToLeft || !textAfter ? DocxShapePictures.widthAfterItsBox(shape) : 0; + // A line set right to left has what comes before the shape on its right. + boolean somethingLeft = rightToLeft ? anythingAfter : anythingBefore; + boolean somethingRight = rightToLeft ? anythingBefore : anythingAfter; + return switch (align == null ? TextAlign.LEFT : align) { + case LEFT -> left + (somethingRight ? right : 0); + case RIGHT -> right + (somethingLeft ? left : 0); + case CENTER -> somethingLeft || somethingRight ? (left + right) / 2 : Math.abs(left - right) / 2; + }; + } + /** * Writes an inline picture or icon where it sits in the line, as a picture in its own run. * @@ -12937,12 +13171,15 @@ private void reportInlinePicture(InlineRun run, String description, String path) * drew itself and the page raises, which carries the rise as transparent rows ({@link * DocxPictureLift}) because LibreOffice ignores {@code w:position} on a picture.

* + * @param widthNotGivenBack how much wider than its box a shape's picture is with no text beside + * it to give that back, in points, for the report * @return how far above the line's bottom the picture reaches — its height where the line * is unmeasured — or {@code null} for a run this does not draw */ private PictureReach writeInlinePicture(XWPFParagraph para, InlineRun run, DocumentLinkTarget fallbackLink, String path, - java.util.Optional line) { + java.util.Optional line, + double widthNotGivenBack) { byte[] bytes; double width; double height; @@ -13021,7 +13258,7 @@ private PictureReach writeInlinePicture(XWPFParagraph para, InlineRun run, Docum // The description is the text the icon stands for, or nothing. describe(picture, description == null ? "" : description); if (reported) { - reportInlinePicture(run, description, path); + reportInlinePicture(run, description, path, widthNotGivenBack); } if (line.isEmpty()) { return PictureReach.unplaced(height); @@ -13261,15 +13498,22 @@ private static String chipLost(InlineBackground background, String leftAs, boole if (background.cornerRadius() > 0) { lost.add("its rounded corners are square"); } + // A padding below zero draws the fill inside the letters' box on the page, and the shading + // covers that box whatever the padding is: one above and below that sum to nothing still + // moves the fill up or down. if (background.padding().left() > 0) { lost.add(leftAs == null ? "its left padding is not in the file" : "its left padding is " + leftAs); + } else if (background.padding().left() < 0) { + lost.add("its left padding, below zero, is not in the file"); } if (background.padding().right() > 0 && !rightWritten) { lost.add("its right padding is not in the file"); + } else if (background.padding().right() < 0) { + lost.add("its right padding, below zero, is not in the file"); } - if (background.padding().vertical() > 0) { + if (background.padding().top() != 0 || background.padding().bottom() != 0) { lost.add("its padding above and below its letters is not in the file"); } if (background.fill().color().getAlpha() < 255) { diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxShapePictures.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxShapePictures.java index 8eb014164..7beeeb5ee 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxShapePictures.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxShapePictures.java @@ -105,6 +105,17 @@ static double widthAfterItsBox(InlineShapeRun run) { return inkOf(run).right() + EDGE; } + /** + * How far an inline shape's picture reaches below its box, in points: its ink's overhang + * there and {@link #EDGE}, as {@link #of} draws it. + * + * @param run the shape + * @return the height the picture takes below the run's box + */ + static double reachBelowItsBox(InlineShapeRun run) { + return inkOf(run).bottom() + EDGE; + } + private static Overhang inkOf(InlineShapeRun run) { Overhang ink = Overhang.NONE; for (ShapeLayer layer : run.layers()) { diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlinePictureLossTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlinePictureLossTest.java new file mode 100644 index 000000000..1c2e92ec0 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlinePictureLossTest.java @@ -0,0 +1,380 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.image.DocumentImageData; +import com.demcha.compose.document.node.InlineImageAlignment; +import com.demcha.compose.document.node.ListMarker; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.node.TextDirection; +import com.demcha.compose.document.node.TextVerticalAlign; +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.style.ShapeOutline; +import com.demcha.compose.document.svg.SvgIcon; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.junit.jupiter.api.Test; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * What a picture among a paragraph's words loses of its place, its width or its paint is named. + * + *

Word stands an inline picture on its line's baseline and moves it by {@code w:position} to + * where the page's alignment sets it, from the layout's measure of the line. Where the lines are + * not read nothing moves it; all of them are moved by the first line's measure; one Word paragraph + * takes one seat by its cap band; a chip's shading covers its letters' box whatever its padding; a + * shape's picture is wider than its box where no text beside it gives that back; an icon's + * gradient is drawn flat. None of these is on the corpus's 62 documents; each is named from half a + * point.

+ * + * @author Artem Demchyshyn + */ +class DocxInlinePictureLossTest { + + private static final SvgIcon ICON = SvgIcon.parse("" + + ""); + + private static final SvgIcon GRADIENT = SvgIcon.parse(""" + + + + + + + """); + + private static final DocumentTextStyle SMALL = DocumentTextStyle.builder().size(8).build(); + private static final DocumentTextStyle LARGE = DocumentTextStyle.builder().size(24).build(); + + /** A stroke that reaches 1.5pt past a dot's box on each side. */ + private static final DocumentStroke STROKE = DocumentStroke.of(DocumentColor.BLACK, 3); + + @Test + void aPictureBesideTextOnALineNotReadIsNamed() throws Exception { + DocxExportReport report = DocxExports.reportWithoutLayout(400, 300, 20, + page -> page.addParagraph(p -> p.inlineText("Call ").inlineSvgIcon(ICON, 12))); + + assertThat(details(report, "ParagraphNode")).anySatisfy(detail -> assertThat(detail) + .contains("its pictures stand on the line's baseline, where the page sets them by their alignment: " + + "its lines are not read here")); + } + + @Test + void aLineOfPicturesAloneIsNotNamed() throws Exception { + // Set at their size, in Word as on the page: there is no text for them to stand off. + DocxExportReport report = DocxExports.reportWithoutLayout(400, 300, 20, + page -> page.addParagraph(p -> p.inlineSvgIcon(ICON, 12))); + + assertThat(details(report, "ParagraphNode")).noneMatch(detail -> detail.contains("baseline")); + } + + @Test + void aPictureOnTheBaselineIsNotNamed() throws Exception { + DocxExportReport report = DocxExports.reportWithoutLayout(400, 300, 20, + page -> page.addParagraph(p -> p.inlineText("Sign ") + .inlineImage(picture(), 12, 12, InlineImageAlignment.BASELINE))); + + assertThat(details(report, "ParagraphNode")).noneMatch(detail -> detail.contains("baseline")); + } + + @Test + void aPictureOffTheBaselineByLessThanHalfAPointIsNotNamed() throws Exception { + DocxExportReport report = DocxExports.reportWithoutLayout(400, 300, 20, + page -> page.addParagraph(p -> p.inlineText("Sign ") + .inlineImage(picture(), 12, 12, InlineImageAlignment.BASELINE, 0.2, null))); + + assertThat(details(report, "ParagraphNode")).noneMatch(detail -> detail.contains("baseline")); + } + + @Test + void aShapeOnTheBaselineReachingBelowItsBoxIsNamed() throws Exception { + // The page stands its box on the baseline, its stroke 1pt and a pixel below it; Word stands + // its picture's foot there, the whole shape that much higher. + DocxExportReport report = DocxExports.reportWithoutLayout(400, 300, 20, + page -> page.addParagraph(p -> p.inlineText("Sign ").shape(ShapeOutline.circle(10), + DocumentColor.BLACK, DocumentStroke.of(DocumentColor.BLACK, 2), InlineImageAlignment.BASELINE, + 0, null))); + + assertThat(details(report, "ParagraphNode")).anySatisfy(detail -> assertThat(detail) + .contains("its pictures stand on the line's baseline, up to 1.25pt from where the page sets them")); + } + + @Test + void aCentredIconBesideTextWhoseLinesAreReadIsNotNamedForTheBaseline() throws Exception { + DocxExportReport report = report(page -> page.addParagraph(p -> p.inlineText("Call ") + .inlineSvgIcon(ICON, 12, InlineImageAlignment.CENTER))); + + assertThat(details(report, "ParagraphNode")).noneMatch(detail -> detail.contains("baseline")); + } + + @Test + void aPictureOnALaterLineOfAnotherHeightIsNamed() throws Exception { + // Its line is as tall as the picture; the first, of text alone, is not: centred by the first + // line's measure, it stands apart from where the page centres it on its own. + DocxExportReport report = report(page -> page.addParagraph(p -> p + .inlineText("Words that run on past the width of this narrow page and onto the next line ") + .inlineImage(picture(), 20, 20, InlineImageAlignment.CENTER))); + + assertThat(details(report, "ParagraphNode")).anySatisfy(detail -> assertThat(detail) + .contains("a picture on a line after its first is set by the first line's measure, up to")); + } + + @Test + void aPictureOnTheFirstLineIsNotNamed() throws Exception { + DocxExportReport report = report(page -> page.addParagraph(p -> p + .inlineImage(picture(), 20, 20, InlineImageAlignment.CENTER) + .inlineText(" Words that run on past the width of this narrow page and onto the next line"))); + + assertThat(details(report, "ParagraphNode")).noneMatch(detail -> detail.contains("after its first")); + } + + @Test + void linesInTwoSizesSeatedByTheirCapBandAreNamed() throws Exception { + // The first line opens with the large word, the second with the small text it runs on in. + DocxExportReport report = report(page -> page.addParagraph(p -> p.verticalAlign(TextVerticalAlign.CENTER) + .inlineText("LARGE ", LARGE) + .inlineText("small words that run on past the width of this narrow page and onto the next", SMALL))); + + assertThat(details(report, "ParagraphNode")).anySatisfy(detail -> assertThat(detail) + .contains("its lines are seated as its first is, a line in another size or face up to")); + } + + @Test + void linesInOneSizeSeatedByTheirCapBandAreNotNamed() throws Exception { + DocxExportReport report = report(page -> page.addParagraph(p -> p.verticalAlign(TextVerticalAlign.CENTER) + .inlineText("words that run on past the width of this narrow page and onto the next", SMALL))); + + assertThat(details(report, "ParagraphNode")).noneMatch(detail -> detail.contains("seated as its first")); + } + + @Test + void aLineOfAPictureAloneDoesNotTakeTheSeat() throws Exception { + // The paragraph is seated by its first line of text, and those are in one size. + DocxExportReport report = report(page -> page.addParagraph(p -> p.verticalAlign(TextVerticalAlign.CENTER) + .inlineImage(picture(), 150, 30, InlineImageAlignment.BASELINE) + .inlineText(" words that run on past the width of this narrow page and onto the next", SMALL))); + + assertThat(details(report, "ParagraphNode")).noneMatch(detail -> detail.contains("seated as its first")); + } + + @Test + void aChipPaddedBelowZeroIsNamed() throws Exception { + DocxExportReport report = report(page -> page.addParagraph(p -> p.inlineText("Status ") + .inlineHighlight("Paid", SMALL, DocumentColor.LIGHT_GRAY, 0, new DocumentInsets(3, -2, -3, -2)) + .inlineText(" today"))); + + assertThat(details(report, "inline chip")).singleElement().asString() + .contains("its left padding, below zero, is not in the file") + .contains("its right padding, below zero, is not in the file") + .contains("its padding above and below its letters is not in the file"); + } + + @Test + void aShapeOpeningItsLineNamesTheWidthNoTextGivesBack() throws Exception { + // Its stroke reaches 1.5pt past its box on each side, and its picture a pixel further: the + // room on its left moves it and its text; the text after it gives back its right. + DocxExportReport report = report(page -> page.addParagraph(p -> p.dot(10, DocumentColor.BLACK, STROKE) + .inlineText(" Item"))); + + assertThat(details(report, "inline shape")).singleElement().asString() + .contains("wider than its box where no text beside it gives that back, and moves it and what stands " + + "beside it in its line up to 1.75pt from where the page sets them"); + } + + @Test + void aShapeBetweenWordsIsNotNamedForItsWidth() throws Exception { + // The text just before it gives back its left, the text after it its right. + DocxExportReport report = report(page -> page.addParagraph(p -> p.inlineText("One ") + .dot(10, DocumentColor.BLACK, STROKE).inlineText(" two"))); + + assertThat(details(report, "inline shape")).singleElement().asString().doesNotContain("wider than its box"); + } + + @Test + void aShapeEndingItsParagraphIsNotNamedForItsWidth() throws Exception { + // Nothing stands right of it for the room on its right to move. + DocxExportReport report = report(page -> page.addParagraph(p -> p.inlineText("One ") + .dot(10, DocumentColor.BLACK, STROKE))); + + assertThat(details(report, "inline shape")).singleElement().asString().doesNotContain("wider than its box"); + } + + @Test + void aShapeBeforePicturesAloneIsNamedForItsRight() throws Exception { + DocxExportReport report = report(page -> page.addParagraph(p -> p.inlineText("One ") + .dot(10, DocumentColor.BLACK, STROKE).inlineSvgIcon(ICON, 12))); + + assertThat(details(report, "inline shape")).singleElement().asString().contains("up to 1.75pt"); + } + + @Test + void aShapeAfterALineBreakIsNamedForItsLeft() throws Exception { + // Word sets no run holding a line break closer: the shape opens Word's line. + DocxExportReport report = report(page -> page.addParagraph(p -> p.inlineText("Java\n") + .dot(10, DocumentColor.BLACK, STROKE).inlineText(" Python"))); + + assertThat(details(report, "inline shape")).singleElement().asString().contains("up to 1.75pt"); + } + + @Test + void aShapeBeforeARunHoldingALineBreakIsNamedForItsRight() throws Exception { + DocxExportReport report = report(page -> page.addParagraph(p -> p.inlineText("One ") + .dot(10, DocumentColor.BLACK, STROKE).inlineText(" two\nthree"))); + + assertThat(details(report, "inline shape")).singleElement().asString().contains("up to 1.75pt"); + } + + @Test + void aShapeOpeningARightAlignedLineIsNotNamedForItsWidth() throws Exception { + // Set from the right, the room on its left moves nothing; its text gives back its right. + DocxExportReport report = report(page -> page.addParagraph(p -> p.align(TextAlign.RIGHT) + .dot(10, DocumentColor.BLACK, STROKE).inlineText(" Online"))); + + assertThat(details(report, "inline shape")).singleElement().asString().doesNotContain("wider than its box"); + } + + @Test + void aShapeEndingARightAlignedLineIsNamedForItsRight() throws Exception { + // Set from the right, the room on its right moves it and the text before it. + DocxExportReport report = report(page -> page.addParagraph(p -> p.align(TextAlign.RIGHT) + .inlineText("Online ").dot(10, DocumentColor.BLACK, STROKE))); + + assertThat(details(report, "inline shape")).singleElement().asString().contains("up to 1.75pt"); + } + + @Test + void aShapeAloneInACentredLineIsNotNamedForItsWidth() throws Exception { + // Its room on either side is alike, and centred, it stands where the page centres it. + DocxExportReport report = report(page -> page.addParagraph(p -> p.align(TextAlign.CENTER) + .dot(10, DocumentColor.BLACK, STROKE))); + + assertThat(details(report, "inline shape")).singleElement().asString().doesNotContain("wider than its box"); + } + + @Test + void aShapeInALineSetRightToLeftIsNamedForItsWidth() throws Exception { + // Text set right to left gives back nothing, and the line is set from the right: the room + // on the shape's right moves it, and both sides what stands left of it, the text after it. + DocxExportReport report = report(page -> page.addParagraph(p -> p.direction(TextDirection.RTL) + .inlineText("שלום ").dot(10, DocumentColor.BLACK, STROKE).inlineText(" עולם"))); + + assertThat(details(report, "inline shape")).singleElement().asString().contains("up to 3.5pt"); + } + + @Test + void aShapeEndingALineSetRightToLeftIsNamedForItsRightAlone() throws Exception { + // Last in reading order, it stands at the line's left, and the line is set from the right: + // the room on its right moves it, and nothing stands left of it for the rest to move. + DocxExportReport report = report(page -> page.addParagraph(p -> p.direction(TextDirection.RTL) + .inlineText("שלום ").dot(10, DocumentColor.BLACK, STROKE))); + + assertThat(details(report, "inline shape")).singleElement().asString().contains("up to 1.75pt"); + } + + @Test + void aShapeInAListItemIsNamedForItsWidth() throws Exception { + // An item's text gives back none of it, before the shape or after it. + DocxExportReport report = report(page -> page.addList(list -> list + .addItem(item -> item.plain("One ").dot(10, DocumentColor.BLACK, STROKE).plain(" two")))); + + assertThat(details(report, "inline shape")).singleElement().asString().contains("up to 3.5pt"); + } + + @Test + void anIconsGradientIsNamed() throws Exception { + DocxExportReport report = report(page -> page.addParagraph(p -> p.inlineText("Rate ").inlineSvgIcon(GRADIENT, 12))); + + assertThat(details(report, "inline icon")).singleElement().asString() + .endsWith("; its gradients are drawn flat, in their first colour"); + } + + @Test + void anIconsGradientStrokeIsNamed() throws Exception { + SvgIcon stroked = SvgIcon.parse(""" + + + + + + + """); + DocxExportReport report = report(page -> page.addParagraph(p -> p.inlineText("Rate ").inlineSvgIcon(stroked, 12))); + + assertThat(details(report, "inline icon")).singleElement().asString() + .endsWith("; its gradients are drawn flat, in their first colour"); + } + + @Test + void anIconInOneColourIsNotNamedForAGradient() throws Exception { + DocxExportReport report = report(page -> page.addParagraph(p -> p.inlineText("Rate ").inlineSvgIcon(ICON, 12))); + + assertThat(details(report, "inline icon")).singleElement().asString().doesNotContain("gradient"); + } + + @Test + void aMarkerWithAPercentSignBeforeADigitIsWrittenAsText() throws Exception { + // Read in a level's text as the level's number, "%1 " showed nothing in Word (measured). + try (XWPFDocument document = DocxExports.withLayout(400, 300, 20, + page -> page.addList(list -> list.marker("%1 ").items("Alpha", "Beta")))) { + XWPFParagraph alpha = document.getParagraphs().stream() + .filter(p -> p.getText().contains("Alpha")).findFirst().orElseThrow(); + + assertThat(alpha.getCTP().getPPr() != null && alpha.getCTP().getPPr().isSetNumPr()) + .as("no Word numbering").isFalse(); + assertThat(alpha.getText()).as("its marker as characters").startsWith("%1"); + } + } + + @Test + void aNestedLevelsMarkerWithAPercentSignBeforeADigitIsWrittenAsText() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 300, 20, + page -> page.addList(list -> list.marker("- ") + .markerFor(1, ListMarker.custom("%2 ")) + .addItem("Alpha", nested -> nested.items("Beta"))))) { + assertThat(document.getParagraphs()) + .filteredOn(p -> p.getText().contains("Alpha") || p.getText().contains("Beta")) + .hasSize(2) + .allSatisfy(p -> assertThat(p.getCTP().getPPr() != null && p.getCTP().getPPr().isSetNumPr()) + .as("no Word numbering").isFalse()); + } + } + + @Test + void aMarkerWithAPercentSignAloneStaysAWordList() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 300, 20, + page -> page.addList(list -> list.marker("50% ").items("Alpha", "Beta")))) { + XWPFParagraph alpha = document.getParagraphs().stream() + .filter(p -> p.getText().contains("Alpha")).findFirst().orElseThrow(); + + assertThat(alpha.getCTP().getPPr().isSetNumPr()).as("Word numbering").isTrue(); + } + } + + private static DocxExportReport report(Consumer content) throws Exception { + return DocxExports.report(200, 300, 20, content); + } + + private static List details(DocxExportReport report, String subject) { + return report.notes().stream().filter(note -> note.subject().equals(subject)) + .map(DocxExportReport.Note::detail).toList(); + } + + private static DocumentImageData picture() { + BufferedImage image = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB); + ByteArrayOutputStream png = new ByteArrayOutputStream(); + try { + javax.imageio.ImageIO.write(image, "png", png); + } catch (java.io.IOException failure) { + throw new java.io.UncheckedIOException(failure); + } + return DocumentImageData.fromBytes(png.toByteArray()); + } +}