From 8a878b1957243027992de3025f8e00b47d42f9bc Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 16 Oct 2026 16:52:21 +0100 Subject: [PATCH 1/2] fix(docx): one bookmark per anchor, no control characters, and margins below zero written A bookmark name a suffix made for one anchor, "Section_1_2" for "Section-1", was given again to the anchor that cleans to it, "Section 1 2", so two links went to one place: a name is now taken only once it is free. An anchor two nodes declare was a bookmark on each, one name twice, where Word goes to the first and the page to the last: the file keeps the last it writes, and the report names the anchor, counted on the nodes the layout registers it on. A control character in text, a bell or a tab, which the page leaves out, was written, most of them as a question mark ("Total? due"). It is taken out of every run of the body, headers and footers, with a space it leaves at an edge kept, and out of a badge's initials, a list level's marker and a picture's description where they are written. A margin below zero on a paragraph, a list or a block round them was written as none. It is an indent below zero in the flow, as far as the page sets the text; in a table cell, a panel's included, the side is held where the cell's text stands and named. The corner-radius note said a panel kept its fill where it had only a border; it names what the panel paints. A markdown test's tab expectation follows the page, which draws no tab. --- CHANGELOG.md | 15 ++ .../architecture/backend-capability-matrix.md | 4 +- docs/recipes/docx-export.md | 4 +- .../semantic/docx/DocxBookmarkNames.java | 28 +++- .../semantic/docx/DocxLayoutMetrics.java | 25 +++ .../semantic/docx/DocxSemanticBackend.java | 152 +++++++++++++++--- .../semantic/docx/DocxTextControls.java | 91 +++++++++++ .../semantic/docx/DocxExportReportTest.java | 30 ++++ .../backend/semantic/docx/DocxLinkTest.java | 105 ++++++++++++ .../semantic/docx/DocxNegativeMarginTest.java | 127 +++++++++++++++ .../docx/DocxSessionMarkdownTest.java | 7 +- .../semantic/docx/DocxTextControlsTest.java | 83 ++++++++++ 12 files changed, 638 insertions(+), 33 deletions(-) create mode 100644 render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTextControls.java create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNegativeMarginTest.java create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextControlsTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e2de1f44..bbb43491e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export keeps each anchor's bookmark its own, writes no control character the page + draws nothing for, sets a margin below zero out past its side, and says what a rounded panel + keeps.** A bookmark name a suffix made for one anchor — "Section-1" after "Section 1" took + `Section_1_2` — was given again to the anchor that cleans to it, "Section 1 2", so two links + went to one place; a name is now taken only once it is free. An anchor two nodes declare was a + bookmark on each, one name twice, where Word goes to the first and the page to the last: the + file now keeps the last it writes, and the report names the anchor — where the page's last is + a node the file writes no bookmark for too. A control character in text — a bell, a null, a + tab — which the page leaves out, was written, most of them as a question mark ("Total? due"), + in runs, headers and footers, a badge's initials, a list's marker and a picture's + description; it is now left out too, the line breaks kept. A margin below zero on a paragraph, + a list or a block round them was written as none, its text at the margin where the page set it + out past it; it is now an indent below zero in the flow, and in a table cell, a panel's + included, it is held where the cell's text stands and named. The corner-radius note on a + rounded panel said it kept its fill where it had only a border; it now says which it keeps. - **A DOCX export writes a page zone the page builds otherwise for the first page into the first page's header or footer.** Word paginates, so a zone's content was built once, for a page that was both the first and the last, and written on every page: a footer reading diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index f691906f9..a36b7bddf 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -63,7 +63,7 @@ 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 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 | +| 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 control character the page draws nothing for 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, and in a table cell is held at the cell's text and 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) | @@ -84,7 +84,7 @@ Payload records live in `core` under | Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ `PptxClipSafety` + raster fallback in `PptxFixedLayoutBackend` — a provably no-op clip (padded content that cannot be cut) skips the fallback entirely and stays native, editable shapes; a clip that can cut ink renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; run-level link hotspots are not emitted and custom fragment handlers do not apply inside the picture; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; the raster targets a 2048px long edge, clamped to between native size and 4x, so a region larger than that is rendered at native resolution rather than downscaled — which also means its transient memory grows with the clip instead of stopping at the target (a 3370pt A0-landscape region costs ~45MB while rendering, against ~17MB for anything up to 2048pt); a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning; the report names a clip that cuts what its layers paint, measured from the layout's fragments by `DocxClipInk` — a clip that cuts nothing (an icon inside its box, a disc's initials, a photo filling its circle) is not named; a picture that fills a container clipped to an ellipse takes the ellipse as its geometry, which both editors crop it to; a badge's glyph — a smaller picture in a painted container that clips it to its outline (`CLIP_PATH`) and holds nothing else but drawing — is drawn by `DocxDrawings` as a floating picture over the outline, where the layout places it, reported `APPROXIMATED` — inside a filled panel the badge and its glyph are drawn in front of the shading; an icon picture beside its text in an unpainted container or a layer stack is drawn the same way; a filled or outlined rectangle or rounded rectangle holding text, composed in a table cell, which has no place in the layout to be drawn at, is written as a panel — a one-cell table in its fill and outline, its corners squared and reported, its row held at least the outline's height less its borders and margins, which keep inside it, and a one-line label the shape centres top to bottom on a line taller than the room Word leaves its content cut alike on both sides to that room, no closer to its letters than three quarters of a point, and seated where the page sets it, from the shape's top where the line is taller than its outline and Word sets it on the shape's middle, and a label it holds whole written at the line height that stands its text on the page's baseline, as far as its letters and the room allow, by a tenth of a point or more and not in a standard face the export does not embed — its row held and its label cut in whole twips rounded down, as the row the cell is in holds the page's height rounded once, and what its letters keep the label past that room taken out of the margins of the cell it is in, where it is all that cell holds and the cell all its row holds; the rest of what a composed cell draws (an icon, a tile, a disc) is the table's own drawing and is drawn by `drawCellDrawing` where the layout puts it, anchored as a rectangle is | | Timeline rail — one logical connector line resolved from marker and entry anchors after layout (`ShapeFragmentPayload` per page) | ✅ `PdfShapeFragmentRenderHandler` — one fragment per page, spliced beneath the markers | ✅ `PptxShapeFragmentRenderHandler` — same payload, same per-page fragments | ⚠️ `DocxDrawings` — the rail is read from the resolved layout's pass fragments and drawn per page as a `line` shape, and the markers as the shapes they are, anchored as a rectangle is: beside an entry's text they move with it when the text above is edited | | Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | ✅ `PptxTransformBegin/EndRenderHandler` (group shape; rotation and centre-pivot scaling via the exterior/interior frame ratio) | ⚠️ inline fallback + one-time capability warning; a turned shape container is written upright, and the report names its transform — in its drawn outline's note, or on its own where the outline draws nothing | -| Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ✅ `PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) | ✅ `DocxSemanticBackend` — an anchor becomes a `w:bookmarkStart` / `w:bookmarkEnd` pair wrapping the paragraph's text, named as Word requires (letters, digits and underscores, starting with a letter, 40 characters); two anchors that clean to one name stay two bookmarks | +| Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ✅ `PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) | ✅ `DocxSemanticBackend` — an anchor becomes a `w:bookmarkStart` / `w:bookmarkEnd` pair wrapping the paragraph's text, named as Word requires (letters, digits and underscores, starting with a letter, 40 characters); two anchors that clean to one name, or to a name a suffix gave another, stay two bookmarks; an anchor two nodes declare is marked on the last node the file writes a bookmark for, and reported | | Bookmark markers (`BookmarkMarkerPayload`) | ✅ `PdfBookmarkMarkerRenderHandler` + `PdfBookmarkOutlineWriter` | ⚠️ `PptxBookmarkMarkerRenderHandler` + `PptxNavigationWriter` (PPTX has no outline tree — the first bookmark on a page names its slide, further bookmarks on the same page are dropped with a debug note) | ✅ `DocxSemanticBackend` — the stated outline level becomes Word's own `HeadingN` style, so the Navigation Pane, the outline view and a generated table of contents all see the document's structure. The style carries the outline level and no formatting, so the paragraph keeps the look its author gave it; only the levels the document uses are defined, and one past Word's nine is clamped. The role is never inferred from type size | | Alpha / opacity | ✅ `PdfAlphaSupport` (`PDExtendedGraphicsState` on every surface — shape fills/strokes, text runs, lines, side borders, table paint) | ✅ native `` via POI on every surface — fills, strokes, text runs, table paint | ⚠️ `DocxTranslucency` — text keeps its alpha as Word's text fill (`w14:textFill` with a transparency, the last of the run's properties, its namespace marked `mc:Ignorable` on the part; `w:color` keeps the colour as authored, which LibreOffice reads with the fill's transparency; an opaque run or list marker under a translucent Normal style writes an opaque fill of its own), and a drawing's fill and outline, a page background and a picture keep theirs; a cell's shading, a border, a rule, a run's shading (a chip) and a text header's separator hold an opaque colour only, so a translucent one is flattened against what Word paints under it — inside a panel or cell the export shaded, that shading as written; on the page, the colour the layout paints there, a page background included and a row's own fill left out, what stands on it being written in the row's panel, or white where a picture, barcode, gradient or transformed fill is under it; a panel's border or a cell's rule against the block's own fill; white under a separator — and named in the report as `translucency` (a chip's on its `inline chip` note). Word saves translucent text into a PDF as drawing, with no text layer | | Text decorations — underline / strikethrough (`DocumentTextDecoration`) | ✅ `PdfTextDecorations` (em-proportional marks: underline −0.10 em, strikethrough +0.28 em, thickness 0.05 em) | ✅ `PptxTextFrames.applyStyle` (PowerPoint draws its own marks — sub-point placement differences vs the PDF's constants) | ✅ `DocxSemanticBackend.applyStyle` (underline maps to Word's single underline, strikethrough to `w:strike`) | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 64b94b4df..ee0555885 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -67,13 +67,13 @@ creation date is real metadata. | Document node | DOCX output | |---|---| -| Paragraphs | Word paragraphs with alignment, font, size, colour (a translucent one with its transparency, as Word's text fill — see "Translucent colours"), bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, as a badge's initials, or in a header or footer; the report names a prefix's letters, and the room a prefix sets lines in by where a path that writes none leaves it out and it moves a line — on the paragraph, or in a header or footer on the zone's note. A session reads markdown unless told not to (`markdown(false)`): it sets a paragraph of plain text's emphasis marks as the style of the text they mark, a heading line bold (the first three levels larger), and drops its marks; the export writes the text as the page sets it — read line by line through the page's own parser, one run a piece in the face, family, colour, tracking and size the page sets it in, `**Java**` a bold run reading `Java`, a linked paragraph's pieces in one link, Word's outline listing a heading by the text written — wherever it writes a paragraph, a page zone's and a badge's included (a badge's initials counted as the page sets them, and written in the flow where they stand in two faces or as a heading, as initials in two runs' faces are). The page's laid-out lines decide it: the pieces are written where those lines hold their letters in their faces, families, colours and tracking, at their sizes — an auto-sized paragraph's read at the size the page fits it to, a heading at its multiple of it; a session that reads no markdown has its marks written as they stand, and text the parser changes nothing of, an underscore inside a word, is written as it stands too. A paragraph composed in a table cell takes only lines that set its pieces so. The page sets a markdown heading in a line as tall as the paragraph's own and draws its letters past it; written in that exact line, Word cuts their tops on screen, and the report names a heading written taller than its line. The faces are the page's: where the session reads markdown its parser sets every piece in a face of its own, the paragraph's left aside, so a bold paragraph's `Senior_Engineer` is written regular, as the page sets it. A paragraph whose lines are not read — with no layout, composed in a table cell whose text, as authored or as the page reads it, no line of its table carries, or a page zone's the layout shows none of — or that the page sets in other letters than its text, as Arabic, which the page shapes before it reads the marks, or that the parser reads into nothing — a lone `*`, an empty list item; `***`, a rule; a line set four spaces in, a block of code — which the page sets as nothing, is written as authored, marks and all, and the report names it, saying where the lines are not read that whether the page reads its marks is not measured, where the page's parser drops one. An auto-sized paragraph (`autoSize`) is written at the size the page fits its text to, read off the layout's lines, on every path that writes a paragraph: a run with a style of its own keeps it, as on the page, and a prefix is measured at the fitted size. Where the lines do not tell it — none are read, they are in sizes that do not say which is the paragraph's, or the paragraph is added at more than one place, which the page fits at each apart — the text is written at its style's size, and the report says the fitted size is not measured, on the paragraph or, in a header or footer, on the zone's note. LibreOffice breaks a badge's initials wider than the square inscribed in its disc, at any size, and hides what wraps; an auto-sized badge, which fills its disc, meets this too. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A paragraph in a table cell, an overlay or a list is left to its container | +| Paragraphs | Word paragraphs with alignment, font, size, colour (a translucent one with its transparency, as Word's text fill — see "Translucent colours"), bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a control character the page draws nothing for — a tab, a bell, a null — is not written either, where the file held a question mark for most of them; a margin below zero on a paragraph, a list or a block round them sets its text out past that side in the flow as far as the page does — in a table cell, a panel's included, the side is held where the cell's text stands, and the report names it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, as a badge's initials, or in a header or footer; the report names a prefix's letters, and the room a prefix sets lines in by where a path that writes none leaves it out and it moves a line — on the paragraph, or in a header or footer on the zone's note. A session reads markdown unless told not to (`markdown(false)`): it sets a paragraph of plain text's emphasis marks as the style of the text they mark, a heading line bold (the first three levels larger), and drops its marks; the export writes the text as the page sets it — read line by line through the page's own parser, one run a piece in the face, family, colour, tracking and size the page sets it in, `**Java**` a bold run reading `Java`, a linked paragraph's pieces in one link, Word's outline listing a heading by the text written — wherever it writes a paragraph, a page zone's and a badge's included (a badge's initials counted as the page sets them, and written in the flow where they stand in two faces or as a heading, as initials in two runs' faces are). The page's laid-out lines decide it: the pieces are written where those lines hold their letters in their faces, families, colours and tracking, at their sizes — an auto-sized paragraph's read at the size the page fits it to, a heading at its multiple of it; a session that reads no markdown has its marks written as they stand, and text the parser changes nothing of, an underscore inside a word, is written as it stands too. A paragraph composed in a table cell takes only lines that set its pieces so. The page sets a markdown heading in a line as tall as the paragraph's own and draws its letters past it; written in that exact line, Word cuts their tops on screen, and the report names a heading written taller than its line. The faces are the page's: where the session reads markdown its parser sets every piece in a face of its own, the paragraph's left aside, so a bold paragraph's `Senior_Engineer` is written regular, as the page sets it. A paragraph whose lines are not read — with no layout, composed in a table cell whose text, as authored or as the page reads it, no line of its table carries, or a page zone's the layout shows none of — or that the page sets in other letters than its text, as Arabic, which the page shapes before it reads the marks, or that the parser reads into nothing — a lone `*`, an empty list item; `***`, a rule; a line set four spaces in, a block of code — which the page sets as nothing, is written as authored, marks and all, and the report names it, saying where the lines are not read that whether the page reads its marks is not measured, where the page's parser drops one. An auto-sized paragraph (`autoSize`) is written at the size the page fits its text to, read off the layout's lines, on every path that writes a paragraph: a run with a style of its own keeps it, as on the page, and a prefix is measured at the fitted size. Where the lines do not tell it — none are read, they are in sizes that do not say which is the paragraph's, or the paragraph is added at more than one place, which the page fits at each apart — the text is written at its style's size, and the report says the fitted size is not measured, on the paragraph or, in a header or footer, on the zone's note. LibreOffice breaks a badge's initials wider than the square inscribed in its disc, at any size, and hides what wraps; an auto-sized badge, which fills its disc, meets this too. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A paragraph in a table cell, an overlay or a list is left to its container | | Lists | Real Word lists: a `numbering.xml` definition per list, `w:numPr` on each item, and the authored marker as the level's text. Nesting is a list level, so Enter continues the list and Tab demotes an item. An item the page reads as markdown is written as the page sets it, as a paragraph is. See "What a list becomes" below for the kinds that stay plain paragraphs | | Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). Each rule comes off as wide as Word draws it, in eighths of a point: a 0.7pt hairline is drawn 0.75pt wide (measured: taken off at 0.7, `EditorialProposal`'s tables and all under them stood a step of Word's grid lower). A cell's rule is written from two to ninety-six eighths of a point, the range of a line border's width. A rule is counted as both editors count it, two and a half twips an eighth rounded down (measured: one of 12.5 twips takes 12, one of 17.5 takes 17), and a rule between two rows is shared in whole twips, the row above it taking half rounded down and the row under it the rest; a cell's top and bottom margins are rounded to the twip together. Tables of one, two, three and six rows ruled at five, six, seven and nine eighths then take the page's height to within a step of Word's grid and to the twip in LibreOffice (measured). A row held at the page's height is written less its margins and those rules too — a rule and a half in the first row and the last, two in a table of one row — since both editors read a row's written height as its cells' content (measured: held less one rule, a table ruled at 0.75pt stood 0.46pt taller in its first row and 0.36pt in its last). A row between two others is written less the rest of its own rule above it and half that of the cell under it, one rule where the two are ruled alike, and any row less the most one of its cells takes, its margins and those rules together (measured: held less its own rule alone, a row ruled at 0.5pt over one ruled at 1.5pt stood half a point taller in Word, and all under it half a point lower). LibreOffice makes room at the edge between two rows for the heavier of their rules, where Word makes it for the lower row's: under a row ruled heavier than it, a row and all under it stand the difference lower there (measured: under a 1.5pt row, 0.5pt rows stood 1pt lower in LibreOffice on Windows and LibreOffice 24.2, and where the page sets them in Word). A cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A row of text cells is moved by its margins to stand its text where the page sets it: the page sets a cell's line its face's ascent below the line's top, Word an exact line's baseline four fifths of the way down it, so every cell's top margin is made smaller and its bottom one as much larger — or the other way — by the least any of its cells calls for, no further than the margin holds, and each cell's text moves with its room, set at the top, the middle or the bottom, while the row keeps its height (measured: a line of 8pt Poppins stood 0.58pt low in Word and 0.55pt in LibreOffice, of Lato 0.24 and 0.25pt high; moved, within 0.04pt and 0.09pt). Not a row holding a composed cell, whose paragraphs their own writers seat where they can, nor a row the layout did not measure, nor one in a vertical merge or whose cells call for opposite ways; a least call under a tenth of a point is left. What the margins leave of a cell's call is moved by its text's position where that is half a point or more, as a paragraph's is: LibreOffice moves raised text further than it is raised, by up to half as much again, which from half a point up is less than what is left. Less, from a tenth of a point, is moved by the height of the cell's lines: each written shorter and the space after it taller by as much, or the other way as far as that space holds, which keeps the cell's content as tall and each line's top where it was, and moves the text four fifths of the difference in both editors — where every paragraph of the cell is one line, in a face whose letters the export reads (not a standard face it does not embed), and its letters stay clear of the line's top and foot, where Word cuts them on screen (measured: `CobaltRota`'s staff names, beside the day cells its chips are composed in, stood 0.25pt low in Word, 0.2pt in LibreOffice on Windows and 0.19pt in LibreOffice 24.2; so, within 0.08pt in all three, every row its height; the line alone written shorter shortened a row whose tallest content it was by as much in LibreOffice 24.2, which holds a row to its cells' content). LibreOffice sets a vertically centred cell's content as if its first paragraph had no space above it, so a cell padded more above than its row's smallest stands its text up to half that difference higher there (measured: 3pt above it stood the line 1.5pt high). A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`) — but the row of a one-row table of paragraphs kept with what follows (see Sections / containers) — `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one. A table or a row the layout moves to a new page keeps its own top edge there, as the page does — written as a line that tall, kept with it, since Word drops a paragraph's space above at the top of a page — while the gap between it and the block before stays at the foot of the page above, where it fits there; a gap the layout carries onto the new page, because it did not fit at the foot of the page above, is not yet held above a table (body paragraphs and spacers: see their rows). A table's margin is its indent and the space round it, and its padding on the sides holds its rows in as the page draws them: its left side is in the indent too, and both are out of the room its columns are given | | Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table, and whose mark is hidden where it is left at the cell's end holding nothing and no space, since LibreOffice lays it out — and takes the width of the column it sits in, less its own margins and padding — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path | | Inline chips (`inlineCode(...)`, `inlineChip(...)`, `highlight(...)`) | The chip's fill becomes the run's own `w:shd`, in a paragraph and in a list item alike. Its shape does not travel — see "What a chip keeps and loses" below | | Images | Embedded pictures at the node's declared size. In the flow, the margin and padding above and below are the space round the picture's paragraph; on the left they are not written, and the report names them, as it does a barcode's and a page reference's sides. A picture drawn beside its text is fitted to its box with its padding in it, which the report names too | -| Links and anchors | A `linkTarget` becomes a `w:hyperlink` — a relationship for an address, `w:anchor` for one of the document's own anchors — and a run's own link wins over the paragraph's — in a list item as much as in a paragraph. An `anchor(...)` becomes a bookmark wrapping that paragraph's text, named as Word requires; on a section, container, table or image it wraps everything the block wrote, from the start of its first paragraph to the end of its last, so a link to a block lands on its first line. A `bookmark(...)` outline level becomes Word's own `HeadingN` style, which is what puts the paragraph in the Navigation Pane, the outline view and a generated table of contents. The style states the outline level and nothing else, so the paragraph keeps its own formatting. The role comes from what the document declared, never from how big the text is | +| Links and anchors | A `linkTarget` becomes a `w:hyperlink` — a relationship for an address, `w:anchor` for one of the document's own anchors — and a run's own link wins over the paragraph's — in a list item as much as in a paragraph. An `anchor(...)` becomes a bookmark wrapping that paragraph's text, named as Word requires; on a section, container, table or image it wraps everything the block wrote, from the start of its first paragraph to the end of its last, so a link to a block lands on its first line. Two anchors that clean to one name, or to a name a suffix gave another, stay two bookmarks; one anchor two nodes declare is one bookmark, the last the file writes one for — the node the page sends a link to and reads a page reference's page from, where that node writes a paragraph to mark — and the report names the anchor. A `bookmark(...)` outline level becomes Word's own `HeadingN` style, which is what puts the paragraph in the Navigation Pane, the outline view and a generated table of contents. The style states the outline level and nothing else, so the paragraph keeps its own formatting. The role comes from what the document declared, never from how big the text is | | Rows | A one-row table spanning the content width — or only its columns, where fixed columns leave part of the row empty — so editors keep the side-by-side layout. The row's slots become the column grid when they are weights, an even split or fixed columns; fixed columns that add up to less than the row leave the rest of it empty, the last one at its own width and the table no wider than its columns, so its text wraps where the page wraps it; the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin; a cell holds whatever its child is, written as it is anywhere else. The row's `verticalAlign` is every cell's `w:vAlign`, so a child shorter than the row sits at its middle or bottom as on the page — a table of contents' leader on its entry's baseline. A row composed in a table cell (`DocumentTableCell.node(...)`), which the layout places nowhere, that the page divides by what its children measure — an arrangement other than `START`, a spacer that grows, an auto column — takes its columns from where its paragraphs' lines start, the table having laid them out, a spacer where the page spreads the room between them; one holding a child those lines do not place — a picture, a shape, or a spacer in a row of stated columns, which gives it a slot of its own — or written inside a panel or another table in its cell has its columns sized by Word to their text, and the report names it. The row is kept whole across a page break, as the layout keeps it. A row is held at least as tall as the page makes it inside a painted panel, and anywhere its tallest child is a drawing Word holds nothing of in its cell — a badge beside a heading. A section or other container in any cell — a row's, a table's, a panel's — that pulls its first line up with a negative top edge writes that one-line paragraph as much shorter, its text seated where the page sets it — no more than the room above its letters, as Word draws an exact line's text only inside the line. A row that paints its box — `fillColor`, `stroke`, `borders` — is a panel holding its columns (see "What a panel keeps and loses" below): a table of one cell carrying the fill and borders, its margins the row's padding as a panel's are, and in it the columns as a table of their own, without the padding; a corner radius squares, and is named (`corner radius`), and what of its borders above and below Word draws past its box is taken, or named, as a panel's is. A row composed in a table cell, in a page zone's line, in a band of overlapping layers or where a stack's column measures the space round it to its text, with a padding below zero or a column hanging into it, or with a margin below zero above or below it or at a side in a cell, is written as its columns alone, its paint in the report (`row paint`); in the body a row bleeding past the margin is its panel | | Sections / containers | Children written in order. A timeline with its markers on the rail (`markerOnRail()`) lays each entry's body out in the header row's content column, below the row; the body is written in the flow, indented to that column where the page puts it. A container with a fill, per-side borders or a uniform stroke is a one-cell table carrying them, its padding as the cell's margins, so a card keeps its panel — see "What a panel keeps and loses" below. A `keepTogether()` or `keepWithNext()` block the layout placed on one page stays on one page in Word too (`w:keepLines` + `w:keepNext`, and a row that may not split for a panel). A table of one row in the body kept with what follows — a row of columns — whose cells hold paragraphs alone is written with a row that may break: LibreOffice reads a one-row table whose row may not break as a table that may not split, and keeps such a table with nothing (measured: `EditorialProposal`'s page-two masthead stood at the foot of page one in LibreOffice, and all of page two 45 to 47pt high; freed, it opens page two in LibreOffice, Word set it there either way, and moved down to straddle the foot of page one it went to page two whole in both). A row holding a table keeps `w:cantSplit`: the keep chains a cell's own paragraphs, not the rows of a table in it. A box with no paint is only its contents, so a `fixedWidth` narrower than the column is not written: its paragraphs and lists run the column's width, as a panel's in a table cell do, and the report names it. Under an alignment wrapper, or as a layer of a band, the box is held in to where the page placed it. A painted section's bleed is not written: its fill and borders stop at its box, and where the page bleeds it — in the flow it lays out page by page, the body and the panels in it — the report names it. A line drawn as a shape holds no keep: its drawing is anchored in a paragraph near it, a page can end between it and the next block, and in that same flow the report names the keep it loses | | Spacers | An empty paragraph a tenth of a point tall, which Word keeps; the rest of the spacer's height is the space above the next block, or below this paragraph when a table follows. A spacer shorter than that tenth — a width anchor of no height, as `Panel`'s and `PanelLetter`'s cards open with — has its line reach past it by the rest, which the space below takes, as it takes a line hanging below a block, and what it cannot take is named (`space above`): taken nowhere, the line stood every card a tenth of a point taller in Word. A spacer in the body the layout moves to a new page with the gap before it, because the gap did not fit at the foot of the page above, holds the space the layout leaves above it there — that gap, and the edges of any containers opening with it — in its line, which Word keeps at the top of a page where it drops the space above. A spacer's own margin and padding are not written, and the report names them — except below the lowest block of a band, or of a layer another resumes after in its column, where the space is measured from the page, the block's own margin and padding included | diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxBookmarkNames.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxBookmarkNames.java index 8d5ef0e11..99f2e6fd2 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxBookmarkNames.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxBookmarkNames.java @@ -1,8 +1,10 @@ package com.demcha.compose.document.backend.semantic.docx; import java.util.HashMap; +import java.util.HashSet; import java.util.Locale; import java.util.Map; +import java.util.Set; /** * The name Word will accept for one of the document's own anchors. @@ -29,6 +31,8 @@ final class DocxBookmarkNames { private final Map byAnchor = new HashMap<>(); private final Map collisions = new HashMap<>(); + /** Every name given, without case. */ + private final Set taken = new HashSet<>(); private int nextId; /** @@ -72,16 +76,24 @@ private String mint(String anchor) { // Two anchors that clean to the same text are still two anchors. Without this the // second one's bookmark would sit on the first one's name and every link to either - // would land in the same place. + // would land in the same place. A name a suffix makes is another anchor's own too: + // "Section-1" became "Section_1_2", which "Section 1 2" cleans to, so a name is taken + // only once it is free. Word reads bookmark names without case. String key = base.toLowerCase(Locale.ROOT); int seen = collisions.merge(key, 1, Integer::sum); - if (seen == 1) { - return base; + // The n-th anchor of a name is tried as name_n first, the first as the name itself. + int number = Math.max(seen, 2) - 1; + String name = seen == 1 ? base : suffixed(base, ++number); + while (!taken.add(name.toLowerCase(Locale.ROOT))) { + name = suffixed(base, ++number); } - String suffix = "_" + seen; - String trimmed = base.length() + suffix.length() > MAX_LENGTH - ? base.substring(0, MAX_LENGTH - suffix.length()) - : base; - return trimmed + suffix; + return name; + } + + /** A name with a number after it, cut to fit Word's length. */ + private static String suffixed(String base, int number) { + String suffix = "_" + number; + return (base.length() + suffix.length() > MAX_LENGTH ? base.substring(0, MAX_LENGTH - suffix.length()) : base) + + suffix; } } diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java index 26db9f22c..34d089ed5 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java @@ -636,6 +636,31 @@ private Map zoneFragments(int zoneIndex, int page, return found; } + /** + * How many nodes declare each anchor the layout registers: the page sends a link to the last. + * A node a repeated table header draws again is one node; a page zone's, for which the file + * holds no bookmark, is left out. + * + * @return each anchor's count of declaring nodes, empty without a layout + */ + Map anchorsDeclared() { + Map> nodes = new HashMap<>(); + for (Map.Entry> entry : fragments.entrySet()) { + if (entry.getKey().startsWith("@page-zone")) { + continue; + } + for (PlacedFragment fragment : entry.getValue()) { + if (fragment.payload() instanceof com.demcha.compose.document.layout.payloads.AnchorMarkerPayload marker + && !marker.anchor().isEmpty()) { + nodes.computeIfAbsent(marker.anchor(), key -> new java.util.HashSet<>()).add(entry.getKey()); + } + } + } + Map counts = new HashMap<>(); + nodes.forEach((anchor, declaring) -> counts.put(anchor, declaring.size())); + return counts; + } + /** * The first page a page zone is drawn on. * 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 50ce521d1..b24e37639 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 @@ -832,6 +832,11 @@ private int startPageOf(DocumentNode node) { private java.util.Set outlineLevelsFromWordsNinth = java.util.Set.of(); // The bookmarks the file holds: each anchor the body wrote one for, and its name. private final java.util.Map bookmarksWritten = new java.util.HashMap<>(); + // The id of each bookmark written for an anchor, in the order written: an anchor two nodes + // declare keeps the last one's (keepTheLastBookmarkOfEachAnchor). + private final java.util.Map> bookmarksOfAnchors = new java.util.LinkedHashMap<>(); + // How many nodes the layouts register each anchor on, a bookmark written or not. + private final java.util.Map anchorsDeclared = new java.util.LinkedHashMap<>(); // The page references written as fields, to be checked against those bookmarks. private final List fieldReferences = new ArrayList<>(); // Where the finished report goes, when the caller configured somewhere for it to go. @@ -1133,6 +1138,8 @@ private byte[] write(List sections, Path outputFile) throws Exc report = new DocxExportReport.Builder(); bookmarkNames = new DocxBookmarkNames(); bookmarksWritten.clear(); + bookmarksOfAnchors.clear(); + anchorsDeclared.clear(); fieldReferences.clear(); java.util.NavigableSet outlineLevels = outlineLevelsIn(whole); headingLevels = outlineLevels.stream().map(level -> Math.min(level, MAX_HEADING_LEVEL)) @@ -1209,10 +1216,12 @@ private byte[] write(List sections, Path outputFile) throws Exc index == sections.size() - 1)); } // Every bookmark is written now, those a reference on an earlier page reads included. + keepTheLastBookmarkOfEachAnchor(document); settleThePageReferences(); hideTheClosingMark(document); hideTheCellClosingMarks(document.getTables()); letTheOneRowTablesKeptWithTheNextBreak(document.getTables()); + DocxTextControls.dropFrom(document); if (textFillsWritten) { DocxTranslucency.settle(document); } @@ -1246,6 +1255,7 @@ private void beginSection(SemanticSection section, int index) { sectionNotesFrom = report.size(); SemanticExportContext context = section.context(); layout = DocxLayoutMetrics.of(section.graph(), context.layoutGraph()); + layout.anchorsDeclared().forEach((anchor, nodes) -> anchorsDeclared.merge(anchor, nodes, Integer::sum)); if (layout.isEmpty()) { // Said once per section, naming it when there are several: without measurements // the line height is Word's, a lineSpacing is put between no lines, and every auto @@ -3376,6 +3386,7 @@ private void bookmarkAround(List written, String anchor) { start.setId(BigInteger.valueOf(id)); start.setName(name); bookmarksWritten.putIfAbsent(anchor, name); + bookmarksOfAnchors.computeIfAbsent(anchor, key -> new ArrayList<>()).add(id); moveToParagraphStart(first.getCTP(), start); last.getCTP().addNewBookmarkEnd().setId(BigInteger.valueOf(id)); } @@ -4769,7 +4780,7 @@ private BigInteger numberingFor(XWPFDocument document, // even when the literal is a digit: Word must draw the marker the author // wrote, not one it derives from the item''s position. level.addNewNumFmt().setVal(STNumberFormat.BULLET); - level.addNewLvlText().setVal(levels.get(depth)); + level.addNewLvlText().setVal(DocxTextControls.kept(levels.get(depth))); level.addNewLvlJc().setVal(STJc.LEFT); CTInd indent = level.addNewPPr().addNewInd(); boolean measured = depth == 0 && column > 0; @@ -4996,8 +5007,14 @@ private void writeList(XWPFDocument document, // Under an overlay too, and the editor's couple of points in a cell come off the right // alone: an item's marker is set from its left edge. double spare = currentCell != null ? EDITOR_SLACK_POINTS : 0; - insetLeft += Math.max(0, (list == leftMarginInCell ? 0 : list.margin().left()) + list.padding().left()); - insetRight += Math.max(0, list.margin().right() + list.padding().right() - spare); + // Pulled out past a side by a margin below zero, its items stand out as far, but in a table + // cell, where the side is held at the cell's text and named (PULLED_OUT_OF_ITS_CELL). + double ownLeft = (list == leftMarginInCell ? 0 : list.margin().left()) + list.padding().left(); + double ownRight = list.margin().right() + list.padding().right(); + boolean inACell = currentCell != null; + insetLeft += ownLeft < 0 && !inACell ? ownLeft : Math.max(0, ownLeft); + insetRight += ownRight < 0 && !inACell ? ownRight : Math.max(0, ownRight - spare); + String pulledOut = inACell && (ownLeft < 0 || ownRight < 0) ? PULLED_OUT_OF_ITS_CELL : null; int atTheirColumn; try { atTheirColumn = writeListItems(document, list, numId); @@ -5024,11 +5041,23 @@ private void writeList(XWPFDocument document, listItemsWritten = previousWritten; } owePendingSpacingAfter(list.margin().bottom() + list.padding().bottom()); + List lost = new ArrayList<>(listLost(list, laidOut, atTheirColumn, written)); + if (pulledOut != null && itemCount(list) > 0) { + lost.add(pulledOut); + } reportWrittenWithout(list, itemCount(list) == 0 ? "writes no paragraph" - : numId != null ? "written as a Word list" : "written as a paragraph per item", - listLost(list, laidOut, atTheirColumn, written)); + : numId != null ? "written as a Word list" : "written as a paragraph per item", lost); } + /** + * What a paragraph or a list a margin below zero pulls out past a side loses in a table cell, + * a panel's included: the side is written where the cell's text stands, the margin not at all. + * Outside one it stands out as far as on the page. + */ + private static final String PULLED_OUT_OF_ITS_CELL = + "its margin below zero is not written: in a table cell its side is held where the cell's text " + + "stands"; + /** * What a list's items lose, as the phrases its note lists. * @@ -7694,6 +7723,28 @@ private static DocumentBorders bordersOf(DocumentBorders borders, DocumentStroke return null; } + /** + * What a rounded panel keeps of its paint in a rectangular Word cell, as the corner-radius + * note says it: "keeps its fill and ", "keeps its border and ", both, or nothing to keep. + */ + private static String keptOf(DocumentNode node) { + boolean fill; + boolean border; + if (node instanceof ShapeContainerNode shape) { + fill = shape.fillColor() != null; + border = shape.stroke() != null && shape.stroke().width() > 0; + } else { + ContainerPaint paint = paintOf(node); + fill = paint.fill() != null; + // A side is painted where its stroke has a width (paintCellSide). + DocumentBorders sides = paint.borders() == null ? DocumentBorders.NONE : paint.borders(); + border = java.util.stream.Stream.of(sides.top(), sides.right(), sides.bottom(), sides.left()) + .anyMatch(side -> side != null && side.width() > 0); + } + return fill && border ? "keeps its fill and border and " : fill ? "keeps its fill and " + : border ? "keeps its border and " : ""; + } + /** One warning per export for the part of a container's design Word cannot hold. */ private void warnContainerRadiusDropped(DocumentNode node) { boolean rounded = node instanceof SectionNode section @@ -7709,8 +7760,7 @@ private void warnContainerRadiusDropped(DocumentNode node) { && hasRadius(corners.corners())); if (rounded) { report.add(DocxExportReport.Severity.APPROXIMATED, "corner radius", layout.pathOf(node), - "a Word table cell is rectangular, so the panel keeps its fill and " - + "loses its rounded corners"); + "a Word table cell is rectangular, so the panel " + keptOf(node) + "loses its rounded corners"); } if (rounded && containerRadiusWarned.compareAndSet(false, true)) { LOG.warn("docx.export.container-radius-dropped node='{}' — a Word table cell " @@ -8937,7 +8987,9 @@ private long leftIndentTwips(double points) { */ private void applyInset(XWPFParagraph para) { double left = insetLeft + cellTextShift; - if (!(Math.abs(insetLeft) > 0.01) && insetRight <= 0) { + // Below zero only outside a table cell, where a side is held at the cell's text. + boolean right = insetRight > 0 || insetRight < -0.01 && currentCell == null; + if (!(Math.abs(insetLeft) > 0.01) && !right) { return; } CTPPr properties = para.getCTP().isSetPPr() ? para.getCTP().getPPr() : para.getCTP().addNewPPr(); @@ -8945,8 +8997,9 @@ private void applyInset(XWPFParagraph para) { if (Math.abs(insetLeft) > 0.01) { indent.setLeft(BigInteger.valueOf(leftIndentTwips(left))); } - if (insetRight > 0) { - indent.setRight(BigInteger.valueOf(toTwips(insetRight))); + // Below zero too, there: a side a margin pulls the paragraph out past stands out as far. + if (right) { + indent.setRight(BigInteger.valueOf(Math.round(insetRight * POINT_TO_TWIP))); } } @@ -9469,6 +9522,53 @@ private record FieldReference(XWPFParagraph paragraph, String subject, String path, String asText, int noteAt, boolean noted) { } + /** + * Keeps one bookmark of an anchor two nodes declare: the last one's. + * + *

A link to an anchor the document declares twice goes, on the page, to its last + * declaration, and a page reference reads that one's page + * ({@link com.demcha.compose.document.snapshot.PageIndex}). Word goes to the first bookmark of + * a name, and a file holding two of one name has them cut down to one by the editor. So the + * ones before the last are taken out, and the report names the anchor — as it does where the + * layout registers it on more nodes than the file writes a bookmark for, a drawn shape's say: + * the last bookmark written is then another node's than the one the page goes to.

+ */ + private void keepTheLastBookmarkOfEachAnchor(XWPFDocument document) { + java.util.Set earlier = new java.util.HashSet<>(); + java.util.Set anchors = new java.util.LinkedHashSet<>(bookmarksOfAnchors.keySet()); + anchors.addAll(anchorsDeclared.keySet()); + for (String anchor : anchors) { + List ids = bookmarksOfAnchors.getOrDefault(anchor, List.of()); + int nodes = anchorsDeclared.getOrDefault(anchor, ids.size()); + if (ids.size() > 1) { + for (int id : ids.subList(0, ids.size() - 1)) { + earlier.add(BigInteger.valueOf(id)); + } + } + if (nodes < 2) { + continue; + } + report.add(DocxExportReport.Severity.APPROXIMATED, "anchor", null, + "\"" + anchor + "\" is the anchor of " + nodes + " nodes: a link to it goes to the last on the " + + "page, and the Word file, one place a bookmark name can mark, marks the last it writes " + + "a bookmark for"); + } + if (earlier.isEmpty()) { + return; + } + String w = "declare namespace w='http://schemas.openxmlformats.org/wordprocessingml/2006/main' "; + for (String element : List.of("bookmarkStart", "bookmarkEnd")) { + for (org.apache.xmlbeans.XmlObject found : document.getDocument().getBody().selectPath(w + ".//w:" + element)) { + if (found instanceof org.openxmlformats.schemas.wordprocessingml.x2006.main.CTMarkupRange range + && earlier.contains(range.getId())) { + try (org.apache.xmlbeans.XmlCursor cursor = found.newCursor()) { + cursor.removeXml(); + } + } + } + } + } + /** * Names every page reference's field after the bookmark its anchor was written under, and * takes the one whose anchor has none back to the number it reads, saying so in its note. @@ -9545,8 +9645,14 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) { boolean fromTheLeft = leftToRight && node.align() == TextAlign.LEFT; boolean fromTheRight = leftToRight && node.align() == TextAlign.RIGHT; double left = (node == leftMarginInCell ? 0 : node.margin().left()) + node.padding().left(); - insetLeft += Math.max(0, left - (fromTheLeft ? 0 : spare)); - insetRight += Math.max(0, node.margin().right() + node.padding().right() - (fromTheRight ? 0 : spare)); + double right = node.margin().right() + node.padding().right(); + // A side the paragraph is pulled out past, by a margin below zero, stands out as far, with + // no slack to spare on it — but in a table cell, where the side is held at the cell's text + // and named (PULLED_OUT_OF_ITS_CELL), and a line of a word set past its box stands out + // alone (letTheLineStandOut). + boolean pulledOutOfItsCell = currentCell != null && (left < 0 || right < 0); + insetLeft += left < 0 && currentCell == null ? left : Math.max(0, left - (fromTheLeft ? 0 : spare)); + insetRight += right < 0 && currentCell == null ? right : Math.max(0, right - (fromTheRight ? 0 : spare)); XWPFParagraph para; double room; try { @@ -9575,8 +9681,12 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) { closeAnchor(para, anchor); lastWrittenNode = node; lastWrittenParagraph = para; - reportWrittenWithout(node, "written as a paragraph", + List lost = new ArrayList<>( paragraphLost(node, false, node.bookmarkOptions() == null ? null : outlineTextOf(node))); + if (pulledOutOfItsCell && (left < 0 || !standsOut)) { + lost.add(PULLED_OUT_OF_ITS_CELL); + } + reportWrittenWithout(node, "written as a paragraph", lost); } /** @@ -12349,6 +12459,7 @@ private int openAnchor(XWPFParagraph para, String anchor) { start.setId(BigInteger.valueOf(id)); start.setName(name); bookmarksWritten.putIfAbsent(anchor, name); + bookmarksOfAnchors.computeIfAbsent(anchor, key -> new ArrayList<>()).add(id); return id; } @@ -12494,8 +12605,9 @@ private XWPFPicture addFittedPicture(XWPFRun run, byte[] bytes, ImageData resolv * @param description the text the picture stands for, or nothing */ private static void describe(XWPFRun run, String description) { - run.getCTR().getDrawingArray(0).getInlineArray(0).getDocPr().setDescr(description); - run.getEmbeddedPictures().get(0).getCTPicture().getNvPicPr().getCNvPr().setDescr(description); + String kept = DocxTextControls.kept(description); + run.getCTR().getDrawingArray(0).getInlineArray(0).getDocPr().setDescr(kept); + run.getEmbeddedPictures().get(0).getCTPicture().getNvPicPr().getCNvPr().setDescr(kept); } /** @@ -12654,10 +12766,14 @@ private void writeTextBadge(XWPFDocument document, ShapeContainerNode badge, Par paragraphLost(initials, roomLostInItsBox(initials), null)); } - /** A paragraph's text when it is text alone, or {@code null} when it holds anything else. */ + /** + * A paragraph's text when it is text alone, or {@code null} when it holds anything else, + * without the control characters the page draws nothing for: written into a shape's text by + * its XML, the file's text pass never reads it ({@link DocxTextControls}). + */ private static String badgeTextOf(ParagraphNode paragraph) { if (paragraph.inlineRuns() == null || paragraph.inlineRuns().isEmpty()) { - return paragraph.text(); + return paragraph.text() == null ? null : DocxTextControls.kept(paragraph.text()); } StringBuilder text = new StringBuilder(); for (InlineRun run : paragraph.inlineRuns()) { @@ -12666,7 +12782,7 @@ private static String badgeTextOf(ParagraphNode paragraph) { } text.append(textRun.text()); } - return text.toString(); + return DocxTextControls.kept(text.toString()); } /** diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTextControls.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTextControls.java new file mode 100644 index 000000000..65296843d --- /dev/null +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTextControls.java @@ -0,0 +1,91 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFHeaderFooter; +import org.apache.xmlbeans.XmlCursor; +import org.apache.xmlbeans.XmlObject; + +/** + * Takes out of a Word file's text the control characters the page draws nothing for. + * + *

The layout removes a control character — a bell, a null, a tab — from the text it sets + * (it is in Unicode's category C), and keeps the line breaks, which it breaks lines at. Written + * to Word, a character XML cannot hold came out as a question mark, "Total? due" where the page + * reads "Total due", and a tab stood as a gap the page does not leave. A line break left in a + * run's text is Word's space, and stays.

+ * + *

Text written into the file as XML rather than as a run's — a badge's initials in its + * shape, a list level's marker, a picture's description — is cleaned where it is written, + * with {@link #kept}: written as XML, a character XML cannot hold is a question mark already.

+ */ +final class DocxTextControls { + + private static final String TEXT_PATH = + "declare namespace w='http://schemas.openxmlformats.org/wordprocessingml/2006/main' .//w:t"; + + private DocxTextControls() { + } + + /** + * Takes the control characters out of the text of a document's body, headers and footers. + * + * @param document the document as written + */ + static void dropFrom(XWPFDocument document) { + dropFrom(document.getDocument()); + // Read off the document's parts: a header or footer a policy creates is one of them, and + // is in no list of the document's headers and footers until the file is read back. + for (org.apache.poi.ooxml.POIXMLDocumentPart part : document.getRelations()) { + if (part instanceof XWPFHeaderFooter headerOrFooter) { + dropFrom(headerOrFooter._getHdrFtr()); + } + } + } + + private static void dropFrom(XmlObject part) { + for (XmlObject text : part.selectPath(TEXT_PATH)) { + try (XmlCursor cursor = text.newCursor()) { + String written = cursor.getTextValue(); + String kept = kept(written); + if (!kept.equals(written)) { + // A space the text now starts or ends with stays: one a control character stood + // before was the run's own, and Word drops an edge space its text does not keep. + if (!kept.isEmpty() && (Character.isWhitespace(kept.charAt(0)) + || Character.isWhitespace(kept.charAt(kept.length() - 1)))) { + cursor.setAttributeText(SPACE, "preserve"); + } + cursor.setTextValue(kept); + } + } + } + } + + /** The {@code xml:space} attribute, which keeps a text's edge spaces. */ + private static final javax.xml.namespace.QName SPACE = + new javax.xml.namespace.QName("http://www.w3.org/XML/1998/namespace", "space"); + + /** + * A text without the control characters the page draws nothing for: every one but the line + * breaks. + * + * @param text the text as written, or {@code null} + * @return the text kept, the same string where there is nothing to take out + */ + static String kept(String text) { + if (text == null) { + return null; + } + StringBuilder kept = null; + for (int index = 0; index < text.length(); index++) { + char character = text.charAt(index); + boolean drawsNothing = Character.getType(character) == Character.CONTROL + && character != '\n' && character != '\r'; + if (drawsNothing && kept == null) { + kept = new StringBuilder(text.length()).append(text, 0, index); + } else if (!drawsNothing && kept != null) { + kept.append(character); + } + } + return kept == null ? text : kept.toString(); + } +} diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExportReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExportReportTest.java index 3bd267a7a..f955a8747 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExportReportTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExportReportTest.java @@ -91,6 +91,36 @@ void whatSurvivesInAnotherFormIsApproximatedRatherThanDropped() throws Exception assertThat(report.notes().get(0).path()).contains("Card"); } + @Test + void aRoundedPanelIsSaidToKeepWhatItPaints() throws Exception { + // A border alone is no fill: the note says what the square cell keeps. + DocxExportReport bordered = reportOf(page -> page.addSection("Card", card -> card + .stroke(com.demcha.compose.document.style.DocumentStroke.of(SURFACE, 1)) + .cornerRadius(DocumentCornerRadius.of(6)) + .addParagraph(p -> p.text("Inside the card")))); + DocxExportReport both = reportOf(page -> page.addSection("Card", card -> card + .fillColor(SURFACE).stroke(com.demcha.compose.document.style.DocumentStroke.of(SURFACE, 1)) + .cornerRadius(DocumentCornerRadius.of(6)) + .addParagraph(p -> p.text("Inside the card")))); + + assertThat(bordered.bySubject().get("corner radius")).extracting(DocxExportReport.Note::detail) + .containsExactly("a Word table cell is rectangular, so the panel keeps its border and loses its " + + "rounded corners"); + assertThat(both.bySubject().get("corner radius")).extracting(DocxExportReport.Note::detail) + .containsExactly("a Word table cell is rectangular, so the panel keeps its fill and border and " + + "loses its rounded corners"); + // Sides of no width paint nothing: there is no border to keep. + DocxExportReport unpainted = reportOf(page -> page.addSection("Card", card -> card + .fillColor(SURFACE) + .borders(com.demcha.compose.document.style.DocumentBorders.all( + com.demcha.compose.document.style.DocumentStroke.of(SURFACE, 0))) + .cornerRadius(DocumentCornerRadius.of(6)) + .addParagraph(p -> p.text("Inside the card")))); + assertThat(unpainted.bySubject().get("corner radius")).extracting(DocxExportReport.Note::detail) + .containsExactly("a Word table cell is rectangular, so the panel keeps its fill and loses its " + + "rounded corners"); + } + @Test void aChartSaysWhatItBecame() throws Exception { ChartData data = ChartData.builder() diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinkTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinkTest.java index 332ab4174..343df6317 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinkTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinkTest.java @@ -122,6 +122,95 @@ void twoAnchorsThatCleanToTheSameTextStayApart() throws Exception { } } + @Test + void aNameASuffixMakesIsNoOtherAnchorsName() throws Exception { + // "Section-1" takes "Section_1_2" after "Section 1"; "Section 1 2" cleans to that name too. + try (XWPFDocument document = exported(page -> page + .addParagraph(p -> p.text("To two").linkTo("Section-1")) + .addParagraph(p -> p.text("To three").linkTo("Section 1 2")) + .addParagraph(p -> p.text("One").anchor("Section 1")) + .addParagraph(p -> p.text("Two").anchor("Section-1")) + .addParagraph(p -> p.text("Three").anchor("Section 1 2")))) { + + assertThat(bookmarkNames(document)).doesNotHaveDuplicates().hasSize(3); + assertThat(bookmarkIn(document, "Two")).isNotEqualTo(bookmarkIn(document, "Three")); + assertThat(linkAnchor(document.getParagraphs().get(0))).isEqualTo(bookmarkIn(document, "Two")); + assertThat(linkAnchor(document.getParagraphs().get(1))).isEqualTo(bookmarkIn(document, "Three")); + } + } + + @Test + void anAnchorTwoNodesDeclareIsMarkedOnTheLastAlone() throws Exception { + // The page sends a link to the last node declaring it; Word goes to a name's first bookmark. + List notes = new java.util.ArrayList<>(); + byte[] docx; + try (com.demcha.compose.document.api.DocumentSession session = com.demcha.compose.GraphCompose.document() + .pageSize(400, 600).margin(com.demcha.compose.document.style.DocumentInsets.of(20)).create()) { + session.pageFlow(page -> page + .addParagraph(p -> p.text("To it").linkTo("dup")) + .addParagraph(p -> p.text("First").anchor("dup")) + .addParagraph(p -> p.text("Second").anchor("dup"))); + docx = session.export(DocxSemanticBackend.builder().reportSink(report -> notes.addAll(report.notes())).build()); + } + try (XWPFDocument document = new XWPFDocument(new java.io.ByteArrayInputStream(docx))) { + assertThat(bookmarkNames(document)).containsExactly("dup"); + assertThat(bookmarkIn(document, "Second")).isEqualTo("dup"); + assertThat(document.getParagraphs()).allSatisfy(paragraph -> assertThat( + paragraph.getCTP().getBookmarkEndList().size()).isEqualTo(paragraph.getCTP().getBookmarkStartList().size())); + } + assertThat(notes).filteredOn(note -> note.subject().equals("anchor")).singleElement() + .satisfies(note -> assertThat(note.detail()).startsWith("\"dup\" is the anchor of 2 nodes")); + } + + @Test + void aBlocksBookmarkBeforeTheLastDeclarationIsTakenOutWhole() throws Exception { + try (XWPFDocument document = exported(page -> page + .addSection(block -> block.anchor("dup").addParagraph("In the block").addParagraph("Still in it")) + .addParagraph(p -> p.text("Later").anchor("dup")))) { + String xml = document.getDocument().xmlText(); + + assertThat(bookmarkNames(document)).containsExactly("dup"); + assertThat(bookmarkIn(document, "Later")).isEqualTo("dup"); + assertThat(count(xml, " page.addSection(card -> { + card.anchor("card").fillColor(com.demcha.compose.document.style.DocumentColor.LIGHT_GRAY); + for (int line = 0; line < 30; line++) { + card.addParagraph("Line " + line); + } + }))) { + List names = document.getTables().stream() + .flatMap(table -> table.getRows().stream()).flatMap(row -> row.getTableCells().stream()) + .flatMap(cell -> cell.getParagraphs().stream()) + .flatMap(p -> p.getCTP().getBookmarkStartList().stream()).map(bookmark -> bookmark.getName()) + .toList(); + String first = document.getTables().get(0).getRow(0).getCell(0).getParagraphs().get(0).getCTP() + .getBookmarkStartList().stream().map(bookmark -> bookmark.getName()).findFirst().orElse(null); + + assertThat(names).containsExactly("card"); + assertThat(first).as("on the panel's first line").isEqualTo("card"); + } + } + + @Test + void anAnchorWhoseLastDeclarationHasNoBookmarkIsNamed() throws Exception { + // A drawn shape is no paragraph a bookmark could mark: the page goes to it, Word to the text. + List notes = new java.util.ArrayList<>(); + try (com.demcha.compose.document.api.DocumentSession session = com.demcha.compose.GraphCompose.document() + .pageSize(400, 600).margin(com.demcha.compose.document.style.DocumentInsets.of(20)).create()) { + session.pageFlow(page -> page + .addParagraph(p -> p.text("First").anchor("dup")) + .addShape(shape -> shape.size(20, 20).anchor("dup"))); + session.export(DocxSemanticBackend.builder().reportSink(report -> notes.addAll(report.notes())).build()); + } + assertThat(notes).filteredOn(note -> note.subject().equals("anchor")).singleElement() + .satisfies(note -> assertThat(note.detail()).startsWith("\"dup\" is the anchor of 2 nodes")); + } + @Test void anAnchorStartingWithADigitIsStillAName() throws Exception { // Word will not take a bookmark whose name starts with anything but a letter, and @@ -207,6 +296,22 @@ private static List bookmarkNames(XWPFDocument document) { .toList(); } + /** The name of the bookmark in the paragraph reading a text, or null where it holds none. */ + private static String bookmarkIn(XWPFDocument document, String text) { + return document.getParagraphs().stream().filter(p -> p.getText().equals(text)) + .flatMap(p -> p.getCTP().getBookmarkStartList().stream()).map(bookmark -> bookmark.getName()) + .findFirst().orElse(null); + } + + private static int count(String xml, String element) { + return xml.split(java.util.regex.Pattern.quote(element), -1).length - 1; + } + + /** The bookmark an internal link in a paragraph points at. */ + private static String linkAnchor(XWPFParagraph paragraph) { + return paragraph.getCTP().getHyperlinkList().get(0).getAnchor(); + } + private static XWPFDocument exported(Consumer content) throws Exception { return DocxExports.withLayout(400, 600, 20, content); } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNegativeMarginTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNegativeMarginTest.java new file mode 100644 index 000000000..8cc282a94 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNegativeMarginTest.java @@ -0,0 +1,127 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTInd; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A paragraph or a list a margin below zero pulls out past a side of the page's text stands out + * as far in Word, as on the page: its indent is written below zero. + * + *

The export held every side at zero, and a paragraph the page set 12pt into the left margin + * stood at the margin in Word, its lines broken elsewhere.

+ * + * @author Artem Demchyshyn + */ +class DocxNegativeMarginTest { + + private static final String TEXT = "Pulled out by twelve points, long enough to wrap onto a second line"; + + @Test + void aParagraphPulledLeftStandsOutPastTheLeftMargin() throws Exception { + java.util.concurrent.atomic.AtomicReference report = new java.util.concurrent.atomic.AtomicReference<>(); + byte[] docx; + try (com.demcha.compose.document.api.DocumentSession session = com.demcha.compose.GraphCompose.document() + .pageSize(300, 300).margin(com.demcha.compose.document.style.DocumentInsets.of(20)).create()) { + session.pageFlow(page -> page.addParagraph(p -> p.text(TEXT).margin(0, 0, 0, -12))); + docx = session.export(new DocxSemanticBackend(report::set)); + } + try (XWPFDocument document = new XWPFDocument(new java.io.ByteArrayInputStream(docx))) { + assertThat(DocxTwips.of(indentOf(paragraph(document)).getLeft())).isEqualTo(-240); + } + assertThat(report.get().notes()).extracting(DocxExportReport.Note::detail) + .as("written as the page sets it, it loses nothing") + .noneMatch(detail -> detail.contains("margin below zero")); + } + + @Test + void aParagraphPulledRightStandsOutPastTheRightMargin() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(300, 300, 20, page -> page + .addParagraph(p -> p.text(TEXT).margin(0, -12, 0, 0)))) { + assertThat(DocxTwips.of(indentOf(paragraph(document)).getRight())).isEqualTo(-240); + } + } + + @Test + void aListPulledLeftSetsItsMarkersOutAsFar() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(300, 300, 20, page -> page + .addList(list -> list.items("One", "Two").margin(0, 0, 0, -12)))) { + CTInd indent = indentOf(paragraph(document)); + assertThat(left(indent) - DocxTwips.of(indent.getHanging())).as("the marker's place").isEqualTo(-240); + } + } + + @Test + void inAPanelAMarginBelowZeroIsKeptInItsCellAndNamed() throws Exception { + // The panel is a Word table cell: there the side is held at the cell's text, and named. + java.util.concurrent.atomic.AtomicReference report = new java.util.concurrent.atomic.AtomicReference<>(); + byte[] docx; + try (com.demcha.compose.document.api.DocumentSession session = com.demcha.compose.GraphCompose.document() + .pageSize(400, 600).margin(com.demcha.compose.document.style.DocumentInsets.of(20)).create()) { + session.pageFlow(page -> page.addSection(panel -> panel + .fillColor(com.demcha.compose.document.style.DocumentColor.LIGHT_GRAY) + .padding(com.demcha.compose.document.style.DocumentInsets.of(6)) + .addParagraph(p -> p.text("Pulled out of its panel").margin(0, 0, 0, -12)))); + docx = session.export(new DocxSemanticBackend(report::set)); + } + try (XWPFDocument document = new XWPFDocument(new java.io.ByteArrayInputStream(docx))) { + XWPFParagraph pulled = document.getTables().get(0).getRow(0).getCell(0).getParagraphs().stream() + .filter(p -> !p.getText().isBlank()).findFirst().orElseThrow(); + CTInd indent = pulled.getCTP().isSetPPr() && pulled.getCTP().getPPr().isSetInd() + ? pulled.getCTP().getPPr().getInd() : null; + assertThat(indent == null || indent.getLeft() == null || DocxTwips.of(indent.getLeft()) >= 0) + .as("no indent below zero, which Word would cut at the cell's side").isTrue(); + } + assertThat(report.get().notes()).extracting(DocxExportReport.Note::detail) + .anySatisfy(detail -> assertThat(detail).contains("its margin below zero is not written")); + } + + @Test + void inAPanelAMarginBelowZeroMovesNothingTheCellWrites() throws Exception { + // Held at the cell's text, the side writes nothing the cell would not: no indent below zero, + // and the paragraph's own room, which the measure Word is given is reckoned from, as without + // the margin; nor does a block inside the panel pulled out write a side below zero. + String plain = panelIndents(inner -> { }, paragraph -> { }); + assertThat(panelIndents(inner -> { }, paragraph -> paragraph.margin(0, 0, 0, -12))).isEqualTo(plain); + assertThat(panelIndents(inner -> { }, paragraph -> paragraph.margin(0, -12, 0, 0))).isEqualTo(plain); + assertThat(panelIndents(inner -> inner.margin(new com.demcha.compose.document.style.DocumentInsets(0, -12, 0, 0)), + paragraph -> { })).isEqualTo(plain); + } + + /** The indents a panel's paragraph is written with, the block round it and its own sides set as asked. */ + private static String panelIndents(java.util.function.Consumer block, + java.util.function.Consumer sides) + throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addSection(panel -> panel + .fillColor(com.demcha.compose.document.style.DocumentColor.LIGHT_GRAY) + .padding(com.demcha.compose.document.style.DocumentInsets.of(6)) + .addSection(inner -> { + block.accept(inner); + inner.addParagraph(paragraph -> { + paragraph.text("Inside the panel, long enough to wrap onto a second line of its own here"); + sides.accept(paragraph); + }); + })))) { + XWPFParagraph written = document.getTables().get(0).getRow(0).getCell(0).getParagraphs().stream() + .filter(p -> !p.getText().isBlank()).findFirst().orElseThrow(); + return written.getCTP().isSetPPr() && written.getCTP().getPPr().isSetInd() + ? written.getCTP().getPPr().getInd().xmlText() : ""; + } + } + + private static XWPFParagraph paragraph(XWPFDocument document) { + return document.getParagraphs().stream().filter(p -> !p.getText().isBlank()).findFirst().orElseThrow(); + } + + private static CTInd indentOf(XWPFParagraph paragraph) { + assertThat(paragraph.getCTP().getPPr().isSetInd()).as("an indent written").isTrue(); + return paragraph.getCTP().getPPr().getInd(); + } + + private static long left(CTInd indent) { + return indent.getLeft() == null ? 0 : DocxTwips.of(indent.getLeft()); + } +} diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSessionMarkdownTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSessionMarkdownTest.java index 80180bf46..5e82a8f25 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSessionMarkdownTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSessionMarkdownTest.java @@ -143,10 +143,11 @@ void aSessionThatReadsNoMarkdownHasItsMarksWrittenAsTheyStand() throws Exception assertThat(paragraph.getText()).isEqualTo("Some **bold** text"); assertThat(paragraph.getRuns()).singleElement().satisfies(run -> assertThat(run.isBold()).isFalse()); assertThat(export.notes("ParagraphNode")).isEmpty(); - // Text the parser keeps whole, its white space and a tab among it, as it stands in either session. + // Text the parser keeps whole, as it stands in either session: its white space with it, + // and the tab among it out, as the page draws none. for (boolean markdown : List.of(false, true)) { - assertThat(export(markdown, page -> page.addParagraph("a_b\tc")).paragraphWith("a_b").getText()) - .as("markdown " + markdown).isEqualTo("a_b\tc"); + assertThat(export(markdown, page -> page.addParagraph("a_b c\td")).paragraphWith("a_b").getText()) + .as("markdown " + markdown).isEqualTo("a_b cd"); } } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextControlsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextControlsTest.java new file mode 100644 index 000000000..1b8ff33fe --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextControlsTest.java @@ -0,0 +1,83 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A control character the page draws nothing for is written nowhere in the Word file's text, + * where XML cannot hold most of them and wrote a question mark. + * + * @author Artem Demchyshyn + */ +class DocxTextControlsTest { + + @Test + void theControlsThePageDrawsNothingForAreTakenOutAndTheLineBreaksStay() { + assertThat(DocxTextControls.kept("Total\u0007 due")).isEqualTo("Total due"); + assertThat(DocxTextControls.kept("Tab\there")).isEqualTo("Tabhere"); + assertThat(DocxTextControls.kept("Nul\u0000l and \u001Fend\u007F")).isEqualTo("Null and end"); + assertThat(DocxTextControls.kept("one\ntwo\r\nthree")).isEqualTo("one\ntwo\r\nthree"); + String plain = "Invoice INV-1"; + assertThat(DocxTextControls.kept(plain)).isSameAs(plain); + } + + @Test + void aBodyParagraphAndATableCellReadAsThePageSetsThem() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addParagraph("Total\u0007 due") + .addParagraph("Tab\there") + .addTable(table -> table.autoColumns(1).row("Cell\u0001 text")))) { + + assertThat(document.getParagraphs()).extracting(XWPFParagraph::getText) + .contains("Total due", "Tabhere") + .noneMatch(text -> text.contains("?")); + assertThat(document.getTables().get(0).getRow(0).getCell(0).getText()).isEqualTo("Cell text"); + } + } + + @Test + void aSpaceAControlCharacterStoodBeforeIsKept() throws Exception { + // "\u0007 due" taken out to " due": an edge space Word keeps only where the text says so. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addParagraph(p -> p.inlineText("Total").inlineText("\u0007 due")))) { + assertThat(document.getDocument().xmlText()).contains(" due"); + } + } + + @Test + void textWrittenAsXmlIsCleanedWhereItIsWritten() throws Exception { + // A list level's marker and a badge's initials go into the file as XML, which writes a + // character it cannot hold as a question mark before any pass reads the file's text. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addList(list -> list.marker("\u0007•").items("One", "Two")) + .addContainer(badge -> badge.name("Badge").ellipse(40, 40) + .fillColor(com.demcha.compose.document.style.DocumentColor.LIGHT_GRAY) + .center(new com.demcha.compose.document.dsl.ParagraphBuilder().text("A\u0007B").build())))) { + String numbering; + try (java.io.InputStream in = document.getPackage().getPart( + org.apache.poi.openxml4j.opc.PackagingURIHelper.createPartName("/word/numbering.xml")).getInputStream()) { + numbering = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + } + assertThat(numbering).contains("w:val=\"•\"").doesNotContain("?•"); + assertThat(document.getDocument().xmlText()).contains(">AB<").doesNotContain("A?B"); + } + } + + @Test + void aFootersTextReadsAsThePageSetsIt() throws Exception { + byte[] docx; + try (com.demcha.compose.document.api.DocumentSession session = com.demcha.compose.GraphCompose.document() + .pageSize(400, 600).margin(com.demcha.compose.document.style.DocumentInsets.of(20)).create()) { + session.chrome().zone(com.demcha.compose.document.output.DocumentPageZone.footer(20, page -> + new com.demcha.compose.document.dsl.ParagraphBuilder().text("Page\u0007 one").build())); + session.pageFlow(page -> page.addParagraph("Body")); + docx = session.export(new DocxSemanticBackend()); + } + try (XWPFDocument document = new XWPFDocument(new java.io.ByteArrayInputStream(docx))) { + assertThat(document.getFooterList().get(0).getText().strip()).isEqualTo("Page one"); + } + } +} From 3f53b5d984ddd9031e352e06e76411c51d78f5c4 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 16 Oct 2026 20:02:05 +0100 Subject: [PATCH 2/2] fix(docx): hold a cell's text no further out than the cell's own, and take out every character the page leaves out In a table cell the paragraph's or list's own margin below zero was held at zero, not the side it made with the blocks round it: 12pt taken back from a block's 20pt of padding stood at 20, named as held at the cell's text, and a block's own margin below zero there was dropped with no note. The running side is held at the cell's text now, and the note names what that holds back. A row's first child the page sets out past the row's left edge starts at it, and is named; one only drawn, an icon, is drawn where the page draws it and is not. The last of a name's bookmarks is the last in the file, whose order is the page's: a block's opens before the nodes inside it, though written after them, so a block and a paragraph inside it sharing an anchor keep the paragraph's, where the page goes. The text pass took out only the controls; the page leaves out every character of Unicode's category C but the direction marks and joiners, so a soft hyphen, a zero-width space, a noncharacter and a private-use one go too, and a barcode's description is cleaned where it is written. --- CHANGELOG.md | 19 +-- .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 2 +- .../semantic/docx/DocxSemanticBackend.java | 110 ++++++++++----- .../semantic/docx/DocxTextControls.java | 41 ++++-- .../backend/semantic/docx/DocxLinkTest.java | 25 +++- .../semantic/docx/DocxNegativeMarginTest.java | 129 +++++++++++++++++- .../semantic/docx/DocxTextControlsTest.java | 18 +++ 8 files changed, 279 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbb43491e..70d5d4af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,14 +15,17 @@ follow semantic versioning; release dates are ISO 8601. went to one place; a name is now taken only once it is free. An anchor two nodes declare was a bookmark on each, one name twice, where Word goes to the first and the page to the last: the file now keeps the last it writes, and the report names the anchor — where the page's last is - a node the file writes no bookmark for too. A control character in text — a bell, a null, a - tab — which the page leaves out, was written, most of them as a question mark ("Total? due"), - in runs, headers and footers, a badge's initials, a list's marker and a picture's - description; it is now left out too, the line breaks kept. A margin below zero on a paragraph, - a list or a block round them was written as none, its text at the margin where the page set it - out past it; it is now an indent below zero in the flow, and in a table cell, a panel's - included, it is held where the cell's text stands and named. The corner-radius note on a - rounded panel said it kept its fill where it had only a border; it now says which it keeps. + a node the file writes no bookmark for too. A character the page leaves out — a control (a + bell, a null, a tab), a soft hyphen, a zero-width space, one of private use — was written, the + controls mostly as a question mark ("Total? due"), in runs, headers and footers, a badge's + initials, a list's marker and a picture's or a barcode's description; it is now left out too, + the line breaks and the direction marks and joiners kept. A margin below zero on a paragraph or + a list, and on the right of a block round them, was written as none, the text at the margin + where the page set it out past it; it is now an indent below zero in the flow. In a table + cell, a panel's included, the text stands as far out as the cell's text and no further, what + is held back named; a row's first child the page sets out past the row's left edge starts at + it, named. The corner-radius note on a rounded panel said it kept its fill where it had only a + border; it now says which it keeps. - **A DOCX export writes a page zone the page builds otherwise for the first page into the first page's header or footer.** Word paginates, so a zone's content was built once, for a page that was both the first and the last, and written on every page: a footer reading diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index a36b7bddf..5aca02675 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -63,7 +63,7 @@ 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 control character the page draws nothing for 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, and in a table cell is held at the cell's text and 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 | +| 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) | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index ee0555885..2fc895549 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -67,7 +67,7 @@ creation date is real metadata. | Document node | DOCX output | |---|---| -| Paragraphs | Word paragraphs with alignment, font, size, colour (a translucent one with its transparency, as Word's text fill — see "Translucent colours"), bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a control character the page draws nothing for — a tab, a bell, a null — is not written either, where the file held a question mark for most of them; a margin below zero on a paragraph, a list or a block round them sets its text out past that side in the flow as far as the page does — in a table cell, a panel's included, the side is held where the cell's text stands, and the report names it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, as a badge's initials, or in a header or footer; the report names a prefix's letters, and the room a prefix sets lines in by where a path that writes none leaves it out and it moves a line — on the paragraph, or in a header or footer on the zone's note. A session reads markdown unless told not to (`markdown(false)`): it sets a paragraph of plain text's emphasis marks as the style of the text they mark, a heading line bold (the first three levels larger), and drops its marks; the export writes the text as the page sets it — read line by line through the page's own parser, one run a piece in the face, family, colour, tracking and size the page sets it in, `**Java**` a bold run reading `Java`, a linked paragraph's pieces in one link, Word's outline listing a heading by the text written — wherever it writes a paragraph, a page zone's and a badge's included (a badge's initials counted as the page sets them, and written in the flow where they stand in two faces or as a heading, as initials in two runs' faces are). The page's laid-out lines decide it: the pieces are written where those lines hold their letters in their faces, families, colours and tracking, at their sizes — an auto-sized paragraph's read at the size the page fits it to, a heading at its multiple of it; a session that reads no markdown has its marks written as they stand, and text the parser changes nothing of, an underscore inside a word, is written as it stands too. A paragraph composed in a table cell takes only lines that set its pieces so. The page sets a markdown heading in a line as tall as the paragraph's own and draws its letters past it; written in that exact line, Word cuts their tops on screen, and the report names a heading written taller than its line. The faces are the page's: where the session reads markdown its parser sets every piece in a face of its own, the paragraph's left aside, so a bold paragraph's `Senior_Engineer` is written regular, as the page sets it. A paragraph whose lines are not read — with no layout, composed in a table cell whose text, as authored or as the page reads it, no line of its table carries, or a page zone's the layout shows none of — or that the page sets in other letters than its text, as Arabic, which the page shapes before it reads the marks, or that the parser reads into nothing — a lone `*`, an empty list item; `***`, a rule; a line set four spaces in, a block of code — which the page sets as nothing, is written as authored, marks and all, and the report names it, saying where the lines are not read that whether the page reads its marks is not measured, where the page's parser drops one. An auto-sized paragraph (`autoSize`) is written at the size the page fits its text to, read off the layout's lines, on every path that writes a paragraph: a run with a style of its own keeps it, as on the page, and a prefix is measured at the fitted size. Where the lines do not tell it — none are read, they are in sizes that do not say which is the paragraph's, or the paragraph is added at more than one place, which the page fits at each apart — the text is written at its style's size, and the report says the fitted size is not measured, on the paragraph or, in a header or footer, on the zone's note. LibreOffice breaks a badge's initials wider than the square inscribed in its disc, at any size, and hides what wraps; an auto-sized badge, which fills its disc, meets this too. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A paragraph in a table cell, an overlay or a list is left to its container | +| Paragraphs | Word paragraphs with alignment, font, size, colour (a translucent one with its transparency, as Word's text fill — see "Translucent colours"), bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a character the page draws nothing for — a control such as a tab or a bell, a soft hyphen, a zero-width space, one of private use — is not written either, where the file held a question mark for most controls, the direction marks and joiners kept; a margin below zero on a paragraph, a list or a block round them sets its text out past that side in the flow as far as the page does — in a table cell, a panel's included, as far as the cell's text and no further, and the report names what is held back; a row's first child the page sets out past the row's left edge starts at it, named; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, as a badge's initials, or in a header or footer; the report names a prefix's letters, and the room a prefix sets lines in by where a path that writes none leaves it out and it moves a line — on the paragraph, or in a header or footer on the zone's note. A session reads markdown unless told not to (`markdown(false)`): it sets a paragraph of plain text's emphasis marks as the style of the text they mark, a heading line bold (the first three levels larger), and drops its marks; the export writes the text as the page sets it — read line by line through the page's own parser, one run a piece in the face, family, colour, tracking and size the page sets it in, `**Java**` a bold run reading `Java`, a linked paragraph's pieces in one link, Word's outline listing a heading by the text written — wherever it writes a paragraph, a page zone's and a badge's included (a badge's initials counted as the page sets them, and written in the flow where they stand in two faces or as a heading, as initials in two runs' faces are). The page's laid-out lines decide it: the pieces are written where those lines hold their letters in their faces, families, colours and tracking, at their sizes — an auto-sized paragraph's read at the size the page fits it to, a heading at its multiple of it; a session that reads no markdown has its marks written as they stand, and text the parser changes nothing of, an underscore inside a word, is written as it stands too. A paragraph composed in a table cell takes only lines that set its pieces so. The page sets a markdown heading in a line as tall as the paragraph's own and draws its letters past it; written in that exact line, Word cuts their tops on screen, and the report names a heading written taller than its line. The faces are the page's: where the session reads markdown its parser sets every piece in a face of its own, the paragraph's left aside, so a bold paragraph's `Senior_Engineer` is written regular, as the page sets it. A paragraph whose lines are not read — with no layout, composed in a table cell whose text, as authored or as the page reads it, no line of its table carries, or a page zone's the layout shows none of — or that the page sets in other letters than its text, as Arabic, which the page shapes before it reads the marks, or that the parser reads into nothing — a lone `*`, an empty list item; `***`, a rule; a line set four spaces in, a block of code — which the page sets as nothing, is written as authored, marks and all, and the report names it, saying where the lines are not read that whether the page reads its marks is not measured, where the page's parser drops one. An auto-sized paragraph (`autoSize`) is written at the size the page fits its text to, read off the layout's lines, on every path that writes a paragraph: a run with a style of its own keeps it, as on the page, and a prefix is measured at the fitted size. Where the lines do not tell it — none are read, they are in sizes that do not say which is the paragraph's, or the paragraph is added at more than one place, which the page fits at each apart — the text is written at its style's size, and the report says the fitted size is not measured, on the paragraph or, in a header or footer, on the zone's note. LibreOffice breaks a badge's initials wider than the square inscribed in its disc, at any size, and hides what wraps; an auto-sized badge, which fills its disc, meets this too. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A paragraph in a table cell, an overlay or a list is left to its container | | Lists | Real Word lists: a `numbering.xml` definition per list, `w:numPr` on each item, and the authored marker as the level's text. Nesting is a list level, so Enter continues the list and Tab demotes an item. An item the page reads as markdown is written as the page sets it, as a paragraph is. See "What a list becomes" below for the kinds that stay plain paragraphs | | Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). Each rule comes off as wide as Word draws it, in eighths of a point: a 0.7pt hairline is drawn 0.75pt wide (measured: taken off at 0.7, `EditorialProposal`'s tables and all under them stood a step of Word's grid lower). A cell's rule is written from two to ninety-six eighths of a point, the range of a line border's width. A rule is counted as both editors count it, two and a half twips an eighth rounded down (measured: one of 12.5 twips takes 12, one of 17.5 takes 17), and a rule between two rows is shared in whole twips, the row above it taking half rounded down and the row under it the rest; a cell's top and bottom margins are rounded to the twip together. Tables of one, two, three and six rows ruled at five, six, seven and nine eighths then take the page's height to within a step of Word's grid and to the twip in LibreOffice (measured). A row held at the page's height is written less its margins and those rules too — a rule and a half in the first row and the last, two in a table of one row — since both editors read a row's written height as its cells' content (measured: held less one rule, a table ruled at 0.75pt stood 0.46pt taller in its first row and 0.36pt in its last). A row between two others is written less the rest of its own rule above it and half that of the cell under it, one rule where the two are ruled alike, and any row less the most one of its cells takes, its margins and those rules together (measured: held less its own rule alone, a row ruled at 0.5pt over one ruled at 1.5pt stood half a point taller in Word, and all under it half a point lower). LibreOffice makes room at the edge between two rows for the heavier of their rules, where Word makes it for the lower row's: under a row ruled heavier than it, a row and all under it stand the difference lower there (measured: under a 1.5pt row, 0.5pt rows stood 1pt lower in LibreOffice on Windows and LibreOffice 24.2, and where the page sets them in Word). A cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A row of text cells is moved by its margins to stand its text where the page sets it: the page sets a cell's line its face's ascent below the line's top, Word an exact line's baseline four fifths of the way down it, so every cell's top margin is made smaller and its bottom one as much larger — or the other way — by the least any of its cells calls for, no further than the margin holds, and each cell's text moves with its room, set at the top, the middle or the bottom, while the row keeps its height (measured: a line of 8pt Poppins stood 0.58pt low in Word and 0.55pt in LibreOffice, of Lato 0.24 and 0.25pt high; moved, within 0.04pt and 0.09pt). Not a row holding a composed cell, whose paragraphs their own writers seat where they can, nor a row the layout did not measure, nor one in a vertical merge or whose cells call for opposite ways; a least call under a tenth of a point is left. What the margins leave of a cell's call is moved by its text's position where that is half a point or more, as a paragraph's is: LibreOffice moves raised text further than it is raised, by up to half as much again, which from half a point up is less than what is left. Less, from a tenth of a point, is moved by the height of the cell's lines: each written shorter and the space after it taller by as much, or the other way as far as that space holds, which keeps the cell's content as tall and each line's top where it was, and moves the text four fifths of the difference in both editors — where every paragraph of the cell is one line, in a face whose letters the export reads (not a standard face it does not embed), and its letters stay clear of the line's top and foot, where Word cuts them on screen (measured: `CobaltRota`'s staff names, beside the day cells its chips are composed in, stood 0.25pt low in Word, 0.2pt in LibreOffice on Windows and 0.19pt in LibreOffice 24.2; so, within 0.08pt in all three, every row its height; the line alone written shorter shortened a row whose tallest content it was by as much in LibreOffice 24.2, which holds a row to its cells' content). LibreOffice sets a vertically centred cell's content as if its first paragraph had no space above it, so a cell padded more above than its row's smallest stands its text up to half that difference higher there (measured: 3pt above it stood the line 1.5pt high). A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`) — but the row of a one-row table of paragraphs kept with what follows (see Sections / containers) — `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one. A table or a row the layout moves to a new page keeps its own top edge there, as the page does — written as a line that tall, kept with it, since Word drops a paragraph's space above at the top of a page — while the gap between it and the block before stays at the foot of the page above, where it fits there; a gap the layout carries onto the new page, because it did not fit at the foot of the page above, is not yet held above a table (body paragraphs and spacers: see their rows). A table's margin is its indent and the space round it, and its padding on the sides holds its rows in as the page draws them: its left side is in the indent too, and both are out of the room its columns are given | | Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table, and whose mark is hidden where it is left at the cell's end holding nothing and no space, since LibreOffice lays it out — and takes the width of the column it sits in, less its own margins and padding — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path | 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 b24e37639..80f05845e 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 @@ -5008,13 +5008,17 @@ private void writeList(XWPFDocument document, // alone: an item's marker is set from its left edge. double spare = currentCell != null ? EDITOR_SLACK_POINTS : 0; // Pulled out past a side by a margin below zero, its items stand out as far, but in a table - // cell, where the side is held at the cell's text and named (PULLED_OUT_OF_ITS_CELL). - double ownLeft = (list == leftMarginInCell ? 0 : list.margin().left()) + list.padding().left(); - double ownRight = list.margin().right() + list.padding().right(); - boolean inACell = currentCell != null; - insetLeft += ownLeft < 0 && !inACell ? ownLeft : Math.max(0, ownLeft); - insetRight += ownRight < 0 && !inACell ? ownRight : Math.max(0, ownRight - spare); - String pulledOut = inACell && (ownLeft < 0 || ownRight < 0) ? PULLED_OUT_OF_ITS_CELL : null; + // cell, a panel's, no further out than the cell's text: what a margin below zero, its own or + // a block's round it, takes past that is not written, and is named (PULLED_OUT_OF_ITS_CELL). + double sideLeft = (list == leftMarginInCell ? 0 : list.margin().left()) + list.padding().left(); + double sideRight = list.margin().right() + list.padding().right(); + double ownLeft = sideLeft < 0 ? sideLeft : Math.max(0, sideLeft); + double ownRight = sideRight < 0 ? sideRight : Math.max(0, sideRight - spare); + double shortLeft = currentCell == null ? 0 : Math.max(0, -(insetLeft + ownLeft)); + double shortRight = currentCell == null ? 0 : Math.max(0, -(insetRight + ownRight)); + insetLeft += ownLeft + shortLeft; + insetRight += ownRight + shortRight; + String pulledOut = shortLeft > 0.01 || shortRight > 0.01 ? PULLED_OUT_OF_ITS_CELL : null; int atTheirColumn; try { atTheirColumn = writeListItems(document, list, numId); @@ -5051,12 +5055,12 @@ private void writeList(XWPFDocument document, /** * What a paragraph or a list a margin below zero pulls out past a side loses in a table cell, - * a panel's included: the side is written where the cell's text stands, the margin not at all. + * a panel's included: as far as the margin takes it past the cell's text, it is not written. * Outside one it stands out as far as on the page. */ private static final String PULLED_OUT_OF_ITS_CELL = - "its margin below zero is not written: in a table cell its side is held where the cell's text " - + "stands"; + "a margin below zero, its own or a block's round it, is not written past its table cell's text: " + + "its side is held where the cell's text stands"; /** * What a list's items lose, as the phrases its note lists. @@ -9531,32 +9535,41 @@ private record FieldReference(XWPFParagraph paragraph, * a name, and a file holding two of one name has them cut down to one by the editor. So the * ones before the last are taken out, and the report names the anchor — as it does where the * layout registers it on more nodes than the file writes a bookmark for, a drawn shape's say: - * the last bookmark written is then another node's than the one the page goes to.

+ * the last bookmark is then another node's than the one the page goes to.

+ * + *

The last is the last in the file, whose order is the page's: a block's bookmark opens at + * its first paragraph, before those of the nodes inside it, though it is written after them.

*/ private void keepTheLastBookmarkOfEachAnchor(XWPFDocument document) { - java.util.Set earlier = new java.util.HashSet<>(); - java.util.Set anchors = new java.util.LinkedHashSet<>(bookmarksOfAnchors.keySet()); - anchors.addAll(anchorsDeclared.keySet()); - for (String anchor : anchors) { - List ids = bookmarksOfAnchors.getOrDefault(anchor, List.of()); - int nodes = anchorsDeclared.getOrDefault(anchor, ids.size()); - if (ids.size() > 1) { - for (int id : ids.subList(0, ids.size() - 1)) { - earlier.add(BigInteger.valueOf(id)); - } + for (String anchor : new java.util.LinkedHashSet<>(anchorsDeclared.keySet())) { + bookmarksOfAnchors.putIfAbsent(anchor, List.of()); + } + for (java.util.Map.Entry> anchor : bookmarksOfAnchors.entrySet()) { + int nodes = anchorsDeclared.getOrDefault(anchor.getKey(), anchor.getValue().size()); + if (nodes >= 2) { + report.add(DocxExportReport.Severity.APPROXIMATED, "anchor", null, + "\"" + anchor.getKey() + "\" is the anchor of " + nodes + " nodes: a link to it goes to the " + + "last on the page, and the Word file, one place a bookmark name can mark, marks the last " + + "it writes a bookmark for"); } - if (nodes < 2) { - continue; + } + String w = "declare namespace w='http://schemas.openxmlformats.org/wordprocessingml/2006/main' "; + // Each name's bookmarks in the file's order: all but the last go. + java.util.Map> byName = new java.util.LinkedHashMap<>(); + for (org.apache.xmlbeans.XmlObject found : document.getDocument().getBody().selectPath(w + ".//w:bookmarkStart")) { + if (found instanceof CTBookmark start && start.getName() != null) { + byName.computeIfAbsent(start.getName(), name -> new ArrayList<>()).add(start); + } + } + java.util.Set earlier = new java.util.HashSet<>(); + for (List starts : byName.values()) { + for (CTBookmark start : starts.subList(0, starts.size() - 1)) { + earlier.add(start.getId()); } - report.add(DocxExportReport.Severity.APPROXIMATED, "anchor", null, - "\"" + anchor + "\" is the anchor of " + nodes + " nodes: a link to it goes to the last on the " - + "page, and the Word file, one place a bookmark name can mark, marks the last it writes " - + "a bookmark for"); } if (earlier.isEmpty()) { return; } - String w = "declare namespace w='http://schemas.openxmlformats.org/wordprocessingml/2006/main' "; for (String element : List.of("bookmarkStart", "bookmarkEnd")) { for (org.apache.xmlbeans.XmlObject found : document.getDocument().getBody().selectPath(w + ".//w:" + element)) { if (found instanceof org.openxmlformats.schemas.wordprocessingml.x2006.main.CTMarkupRange range @@ -9647,12 +9660,16 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) { double left = (node == leftMarginInCell ? 0 : node.margin().left()) + node.padding().left(); double right = node.margin().right() + node.padding().right(); // A side the paragraph is pulled out past, by a margin below zero, stands out as far, with - // no slack to spare on it — but in a table cell, where the side is held at the cell's text - // and named (PULLED_OUT_OF_ITS_CELL), and a line of a word set past its box stands out - // alone (letTheLineStandOut). - boolean pulledOutOfItsCell = currentCell != null && (left < 0 || right < 0); - insetLeft += left < 0 && currentCell == null ? left : Math.max(0, left - (fromTheLeft ? 0 : spare)); - insetRight += right < 0 && currentCell == null ? right : Math.max(0, right - (fromTheRight ? 0 : spare)); + // no slack to spare on it — but in a table cell, a panel's, its text stands no further out + // than the cell's: what a margin below zero, its own or a block's round it, takes past that + // is not written, and is named (PULLED_OUT_OF_ITS_CELL), unless a line of a word set past + // its box stands out alone (letTheLineStandOut). + double ownLeft = left < 0 ? left : Math.max(0, left - (fromTheLeft ? 0 : spare)); + double ownRight = right < 0 ? right : Math.max(0, right - (fromTheRight ? 0 : spare)); + double shortLeft = currentCell == null ? 0 : Math.max(0, -(insetLeft + ownLeft)); + double shortRight = currentCell == null ? 0 : Math.max(0, -(insetRight + ownRight)); + insetLeft += ownLeft + shortLeft; + insetRight += ownRight + shortRight; XWPFParagraph para; double room; try { @@ -9683,7 +9700,7 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) { lastWrittenParagraph = para; List lost = new ArrayList<>( paragraphLost(node, false, node.bookmarkOptions() == null ? null : outlineTextOf(node))); - if (pulledOutOfItsCell && (left < 0 || !standsOut)) { + if (shortLeft > 0.01 || shortRight > 0.01 && !standsOut) { lost.add(PULLED_OUT_OF_ITS_CELL); } reportWrittenWithout(node, "written as a paragraph", lost); @@ -12918,7 +12935,7 @@ private void writeBarcode(XWPFDocument document, com.demcha.compose.document.nod Units.toEMU(node.width()), Units.toEMU(node.height())); } // A reader's screen reader has only the picture's description to go on: the data is it. - run.getCTR().getDrawingArray(0).getInlineArray(0).getDocPr().setDescr(data.getContent()); + run.getCTR().getDrawingArray(0).getInlineArray(0).getDocPr().setDescr(DocxTextControls.kept(data.getContent())); StringBuilder message = new StringBuilder( "written as a picture of the symbol at its size, which scans as the page's does; " + "its data is part of the picture and is not editable in Word"); @@ -16444,6 +16461,7 @@ private RowGeometry applyRowGeometry(XWPFTable table, RowNode node, double hang, // The layout placed each child, so every way a row can divide — the two that // measure their children included — is already answered. List columns = new ArrayList<>(withRowEditorSlack(node, placedColumns(node, starts))); + reportTheFirstChildPulledOut(node, columns); holdTheLastFixedColumn(node, columns); double width = starts[0] - (inItsPanel ? withoutTheRowsPadding(node, columns) : 0); double taken = takeHang(node, columns, hang); @@ -16463,6 +16481,7 @@ private RowGeometry applyRowGeometry(XWPFTable table, RowNode node, double hang, double[] composed = inItsPanel ? null : composedRowStarts(node); if (composed != null) { List columns = new ArrayList<>(withRowEditorSlack(node, placedColumns(node, composed))); + reportTheFirstChildPulledOut(node, columns); holdTheLastFixedColumn(node, columns); double taken = takeHang(node, columns, hang); setTableWidth(table, Math.min(composed[0], widthOf(columns) + taken) - taken); @@ -16749,6 +16768,25 @@ private record CellColumn(double width, double leading, double trailing) { * shape {@link #statedColumns} builds — the difference is only where the starts come * from, and these were measured rather than worked out.

*/ + /** + * Names a row's first child the page sets out past the row's left edge — a margin below zero + * on it: the first Word cell starts at the row's edge, and its text there. A later child's is + * held, its column starting where the child does, and one only drawn — an icon — is drawn + * where the page draws it, whatever its cell. + */ + private void reportTheFirstChildPulledOut(RowNode node, List columns) { + if (columns.isEmpty() || node.children().isEmpty() || !(columns.get(0).leading() < -0.01)) { + return; + } + DocumentNode first = node.children().get(0); + if (onlyDrawn(first)) { + return; + } + report.add(DocxExportReport.Severity.APPROXIMATED, first.nodeKind(), layout.pathOf(first), + "the page sets it " + Math.round(-columns.get(0).leading() * 10) / 10.0 + + "pt out past its row's left edge, where its Word cell starts: it is written from the edge"); + } + private static List placedColumns(RowNode node, double[] starts) { int count = starts.length - 1; double rowWidth = starts[0]; diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTextControls.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTextControls.java index 65296843d..ce36d259c 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTextControls.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTextControls.java @@ -1,18 +1,22 @@ package com.demcha.compose.document.backend.semantic.docx; +import com.demcha.compose.engine.text.TextControlSanitizer; import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.usermodel.XWPFHeaderFooter; import org.apache.xmlbeans.XmlCursor; import org.apache.xmlbeans.XmlObject; /** - * Takes out of a Word file's text the control characters the page draws nothing for. + * Takes out of a Word file's text the characters the page draws nothing for. * - *

The layout removes a control character — a bell, a null, a tab — from the text it sets - * (it is in Unicode's category C), and keeps the line breaks, which it breaks lines at. Written - * to Word, a character XML cannot hold came out as a question mark, "Total? due" where the page - * reads "Total due", and a tab stood as a gap the page does not leave. A line break left in a - * run's text is Word's space, and stays.

+ *

The layout removes from the text it sets every character of Unicode's category C — a + * control (a bell, a null, a tab), a format character (a soft hyphen, a zero-width space), one + * of private use or none assigned — but the formatting controls it reads, the direction marks + * and the joiners ({@link TextControlSanitizer#removeExceptFormattingControls}), and it keeps the + * line breaks, which it breaks lines at. Written to Word, one XML cannot hold came out as a + * question mark, "Total? due" where the page reads "Total due"; a tab stood as a gap the page + * does not leave, and a soft hyphen or a zero-width space let Word break a word the page sets + * whole. A line break left in a run's text is Word's space, and stays.

* *

Text written into the file as XML rather than as a run's — a badge's initials in its * shape, a list level's marker, a picture's description — is cleaned where it is written, @@ -27,7 +31,8 @@ private DocxTextControls() { } /** - * Takes the control characters out of the text of a document's body, headers and footers. + * Takes the characters the page draws nothing for out of the text of a document's body, + * headers and footers. * * @param document the document as written */ @@ -65,8 +70,8 @@ private static void dropFrom(XmlObject part) { new javax.xml.namespace.QName("http://www.w3.org/XML/1998/namespace", "space"); /** - * A text without the control characters the page draws nothing for: every one but the line - * breaks. + * A text without the characters the page draws nothing for: those of category C but the + * formatting controls and the line breaks. * * @param text the text as written, or {@code null} * @return the text kept, the same string where there is nothing to take out @@ -76,16 +81,24 @@ static String kept(String text) { return null; } StringBuilder kept = null; - for (int index = 0; index < text.length(); index++) { - char character = text.charAt(index); - boolean drawsNothing = Character.getType(character) == Character.CONTROL - && character != '\n' && character != '\r'; + for (int index = 0; index < text.length(); ) { + int codePoint = text.codePointAt(index); + boolean drawsNothing = codePoint != '\n' && codePoint != '\r' && isCategoryC(codePoint) + && !TextControlSanitizer.isFormattingControl(codePoint); if (drawsNothing && kept == null) { kept = new StringBuilder(text.length()).append(text, 0, index); } else if (!drawsNothing && kept != null) { - kept.append(character); + kept.appendCodePoint(codePoint); } + index += Character.charCount(codePoint); } return kept == null ? text : kept.toString(); } + + /** Whether a code point is of Unicode's category C, as the layout reads it. */ + private static boolean isCategoryC(int codePoint) { + int type = Character.getType(codePoint); + return type == Character.CONTROL || type == Character.FORMAT || type == Character.PRIVATE_USE + || type == Character.SURROGATE || type == Character.UNASSIGNED; + } } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinkTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinkTest.java index 343df6317..1c975e5b2 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinkTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinkTest.java @@ -125,17 +125,19 @@ void twoAnchorsThatCleanToTheSameTextStayApart() throws Exception { @Test void aNameASuffixMakesIsNoOtherAnchorsName() throws Exception { // "Section-1" takes "Section_1_2" after "Section 1"; "Section 1 2" cleans to that name too. + // Named in the order written: the anchors first, the links to them after. try (XWPFDocument document = exported(page -> page - .addParagraph(p -> p.text("To two").linkTo("Section-1")) - .addParagraph(p -> p.text("To three").linkTo("Section 1 2")) .addParagraph(p -> p.text("One").anchor("Section 1")) .addParagraph(p -> p.text("Two").anchor("Section-1")) - .addParagraph(p -> p.text("Three").anchor("Section 1 2")))) { + .addParagraph(p -> p.text("Three").anchor("Section 1 2")) + .addParagraph(p -> p.text("To two").linkTo("Section-1")) + .addParagraph(p -> p.text("To three").linkTo("Section 1 2")))) { assertThat(bookmarkNames(document)).doesNotHaveDuplicates().hasSize(3); - assertThat(bookmarkIn(document, "Two")).isNotEqualTo(bookmarkIn(document, "Three")); - assertThat(linkAnchor(document.getParagraphs().get(0))).isEqualTo(bookmarkIn(document, "Two")); - assertThat(linkAnchor(document.getParagraphs().get(1))).isEqualTo(bookmarkIn(document, "Three")); + assertThat(bookmarkIn(document, "Two")).isEqualTo("Section_1_2"); + assertThat(bookmarkIn(document, "Three")).isEqualTo("Section_1_2_2"); + assertThat(linkAnchor(document.getParagraphs().get(3))).isEqualTo(bookmarkIn(document, "Two")); + assertThat(linkAnchor(document.getParagraphs().get(4))).isEqualTo(bookmarkIn(document, "Three")); } } @@ -175,6 +177,17 @@ void aBlocksBookmarkBeforeTheLastDeclarationIsTakenOutWhole() throws Exception { } } + @Test + void aBlockAndANodeInsideItSharingAnAnchorKeepTheInnersBookmark() throws Exception { + // The page registers the block before what it holds, and goes to the last: the paragraph + // inside. The block's bookmark opens before it in the file, though it is written after. + try (XWPFDocument document = exported(page -> page + .addSection(block -> block.anchor("x").addParagraph("Opening").addParagraph(p -> p.text("Inner").anchor("x"))))) { + assertThat(bookmarkNames(document)).containsExactly("x"); + assertThat(bookmarkIn(document, "Inner")).isEqualTo("x"); + } + } + @Test void anAnchoredPanelRunOntoTheNextPageIsMarkedWhereItStarts() throws Exception { try (XWPFDocument document = DocxExports.withLayout(300, 200, 20, page -> page.addSection(card -> { diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNegativeMarginTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNegativeMarginTest.java index 8cc282a94..4709684d1 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNegativeMarginTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNegativeMarginTest.java @@ -3,6 +3,7 @@ import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.junit.jupiter.api.Test; +import com.demcha.compose.document.style.DocumentInsets; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTInd; import static org.assertj.core.api.Assertions.assertThat; @@ -76,7 +77,133 @@ void inAPanelAMarginBelowZeroIsKeptInItsCellAndNamed() throws Exception { .as("no indent below zero, which Word would cut at the cell's side").isTrue(); } assertThat(report.get().notes()).extracting(DocxExportReport.Note::detail) - .anySatisfy(detail -> assertThat(detail).contains("its margin below zero is not written")); + .anySatisfy(detail -> assertThat(detail).contains(PULLED_OUT)); + } + + @Test + void inAPanelAMarginBelowZeroWithinABlocksPaddingIsWritten() throws Exception { + // 20pt of a block's padding, 12 of them taken back: the text stands 8pt into the cell, as + // on the page, which loses nothing. + Report exported = report(page -> page.addSection(panel -> panel.fillColor(GRAY).padding(DocumentInsets.of(6)) + .addSection(block -> block.padding(new DocumentInsets(0, 0, 0, 20)) + .addParagraph(p -> p.text(TEXT).margin(0, 0, 0, -12))))); + XWPFParagraph written = firstCellParagraph(exported.document()); + + assertThat(DocxTwips.of(indentOf(written).getLeft())).isEqualTo(160); + assertThat(exported.details()).noneMatch(detail -> detail.contains(PULLED_OUT)); + } + + @Test + void inAPanelABlocksMarginBelowZeroIsHeldAtTheCellsTextAndNamed() throws Exception { + Report exported = report(page -> page.addSection(panel -> panel.fillColor(GRAY).padding(DocumentInsets.of(6)) + .addSection(block -> block.margin(new DocumentInsets(0, 0, 0, -12)).addParagraph(TEXT)))); + + assertThat(exported.details()).anySatisfy(detail -> assertThat(detail).contains(PULLED_OUT)); + } + + @Test + void aListInAPanelPulledOutIsNamed() throws Exception { + Report exported = report(page -> page.addSection(panel -> panel.fillColor(GRAY).padding(DocumentInsets.of(6)) + .addList(list -> list.items("One", "Two").margin(0, 0, 0, -12)))); + + assertThat(exported.details()).anySatisfy(detail -> assertThat(detail).contains(PULLED_OUT)); + } + + @Test + void aWordALineStandsOutForIsNotNamedWhereOtherWordsAre() throws Exception { + // The one word of a column is let stand out past its box; words a line can break between + // are not, and keep the cell's side. + Report word = report(page -> page.addRow(row -> row.weights(4, 1).addParagraph("Name") + .addParagraph(p -> p.text("linkedin.com/in/alexmorgan").margin(new DocumentInsets(0, -60, 0, 0))))); + Report words = report(page -> page.addRow(row -> row.weights(4, 1).addParagraph("Name") + .addParagraph(p -> p.text("My profile page linkedin.com/in/alexmorgan") + .margin(new DocumentInsets(0, -60, 0, 0))))); + + assertThat(word.details()).noneMatch(detail -> detail.contains(PULLED_OUT)); + assertThat(words.details()).anySatisfy(detail -> assertThat(detail).contains(PULLED_OUT)); + } + + @Test + void aRowsFirstChildPulledOutPastTheRowIsNamed() throws Exception { + // Its Word cell starts at the row's edge: a later child's column starts where the child does. + Report exported = report(page -> page.addRow(row -> row.weights(1, 1) + .addParagraph(p -> p.text("Pulled out").margin(0, 0, 0, -12)) + .addParagraph("Right"))); + + assertThat(exported.details()).anySatisfy(detail -> assertThat(detail) + .startsWith("the page sets it 12.0pt out past its row's left edge")); + } + + @Test + void aRowsFirstIconPulledOutIsDrawnWhereThePageDrawsItAndNotNamed() throws Exception { + // An icon is drawn as shapes where the layout puts it, whatever cell it stands in. + com.demcha.compose.document.svg.SvgIcon icon = com.demcha.compose.document.svg.SvgIcon.parse( + ""); + Report exported = report(page -> page.addRow(row -> row.weights(1, 4) + .addSection(glyph -> glyph.margin(new DocumentInsets(0, 0, 0, -4)) + .addSvgIcon(icon, 10, com.demcha.compose.document.node.HorizontalAlign.LEFT)) + .addParagraph("Due on receipt"))); + + assertThat(exported.details()).noneMatch(detail -> detail.contains("out past its row's left edge")); + } + + @Test + void aBlockPulledRightInTheFlowSetsItsTextOutAsFar() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(300, 300, 20, page -> page + .addSection(block -> block.margin(new DocumentInsets(0, -12, 0, 0)).addParagraph(TEXT)))) { + assertThat(DocxTwips.of(indentOf(paragraph(document)).getRight())).isEqualTo(-240); + } + } + + @Test + void aPictureInAPanelsBlockPulledRightWritesNoSideBelowZero() throws Exception { + // Held at the cell's text, as a paragraph's is: the picture's paragraph keeps the cell's side. + Report exported = report(page -> page.addSection(panel -> panel.fillColor(GRAY).padding(DocumentInsets.of(6)) + .addSection(block -> block.margin(new DocumentInsets(0, -12, 0, 0)) + .addImage(image -> image.source(png()).size(20, 20))))); + XWPFParagraph picture = exported.document().getTables().get(0).getRow(0).getCell(0).getParagraphs().stream() + .filter(p -> !p.getRuns().isEmpty() && !p.getRuns().get(0).getEmbeddedPictures().isEmpty()) + .findFirst().orElseThrow(); + CTInd indent = picture.getCTP().isSetPPr() && picture.getCTP().getPPr().isSetInd() + ? picture.getCTP().getPPr().getInd() : null; + + assertThat(indent == null || indent.getRight() == null || DocxTwips.of(indent.getRight()) >= 0).isTrue(); + } + + private static byte[] png() { + java.awt.image.BufferedImage image = new java.awt.image.BufferedImage(4, 4, java.awt.image.BufferedImage.TYPE_INT_RGB); + try (java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) { + javax.imageio.ImageIO.write(image, "png", out); + return out.toByteArray(); + } catch (java.io.IOException failure) { + throw new java.io.UncheckedIOException(failure); + } + } + + private static final String PULLED_OUT = "is not written past its table cell's text"; + + private static final com.demcha.compose.document.style.DocumentColor GRAY = + com.demcha.compose.document.style.DocumentColor.LIGHT_GRAY; + + private record Report(XWPFDocument document, java.util.List details) { + } + + private static Report report(java.util.function.Consumer content) + throws Exception { + java.util.concurrent.atomic.AtomicReference report = new java.util.concurrent.atomic.AtomicReference<>(); + byte[] docx; + try (com.demcha.compose.document.api.DocumentSession session = com.demcha.compose.GraphCompose.document() + .pageSize(400, 600).margin(DocumentInsets.of(20)).create()) { + session.pageFlow(content); + docx = session.export(new DocxSemanticBackend(report::set)); + } + return new Report(new XWPFDocument(new java.io.ByteArrayInputStream(docx)), + report.get().notes().stream().map(DocxExportReport.Note::detail).toList()); + } + + private static XWPFParagraph firstCellParagraph(XWPFDocument document) { + return document.getTables().get(0).getRow(0).getCell(0).getParagraphs().stream() + .filter(p -> !p.getText().isBlank()).findFirst().orElseThrow(); } @Test diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextControlsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextControlsTest.java index 1b8ff33fe..04bc2d052 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextControlsTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextControlsTest.java @@ -24,6 +24,24 @@ void theControlsThePageDrawsNothingForAreTakenOutAndTheLineBreaksStay() { assertThat(DocxTextControls.kept(plain)).isSameAs(plain); } + @Test + void theRestOfCategoryCGoesTooButTheFormattingControlsThePageReads() { + // A soft hyphen and a zero-width space would let Word break a word the page sets whole; a + // noncharacter is a question mark in XML. The direction marks and the joiners steer the + // page's text, and stay. + assertThat(DocxTextControls.kept("co­op​er￿ate")).isEqualTo("cooperate"); + assertThat(DocxTextControls.kept("‏a‍b‌c")).isEqualTo("‏a‍b‌c"); + assertThat(DocxTextControls.kept("😀 smile")).as("an emoji is no surrogate").isEqualTo("😀 smile"); + } + + @Test + void aBarcodesDescriptionReadsItsDataAsThePageDoes() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addBarcode(barcode -> barcode.code128().data("A\u001DB").size(120, 40)))) { + assertThat(document.getDocument().xmlText()).contains("descr=\"AB\"").doesNotContain("descr=\"A?B\""); + } + } + @Test void aBodyParagraphAndATableCellReadAsThePageSetsThem() throws Exception { try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page