diff --git a/CHANGELOG.md b/CHANGELOG.md index a63a8b354..48da56314 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A picture, a shape or a barcode composed in a table cell keeps its own size.** The layout gave + a node composed in a table cell (`DocumentTableCell.node`) the cell's inner width, and the + height it measured. In a 120pt column padded 4pt, the page drew: + - a picture fitted to 100pt in a box 112pt wide, so 112×56 for 100×50; + - a 60pt picture stretched to 112×60; + - a 20pt circle as an ellipse 112pt wide. + + The DOCX export wrote the pictures at their own size, and drew the circle as the page did. + - A node of a size of its own is now laid out at the width it measured, as the flow and a row + slot lay one out: a picture, a shape, a circle, a barcode, a line, a path or a polygon. It is + held to the cell's inner width where it measures wider, and starts at the cell's content edge + as before. A link on it covers it rather than the cell's width. + - Each of the three now stands where Word and LibreOffice draw it. + - Text aligns in the cell's inner width as before: a paragraph, a list, a page number or a page + reference. A composite is laid out in the cell's inner box as before. + - The DOCX export draws a circle or a shape there at its own size, as the page now does, and + measures a shape's corners against its own width. - **A DOCX export keeps the space above a picture the layout moves to a new page.** Word drops a paragraph's space above at the top of a page, a picture's paragraph's too. Measured in Word and LibreOffice on Windows, a 100pt picture opening its page stood at the page's top in both: 20pt @@ -315,8 +332,8 @@ follow semantic versioning; release dates are ISO 8601. tell that box apart from the node. A box whose corners round by radii that differ only past half its shorter side — 10 and 12 on a box 20 tall — is drawn at 10 on the page and in Word alike, and its note no longer says its corners are drawn at the largest. A shape composed in a - table cell is laid out at the cell's width, which the report does not know, so its radii are - held to half its height: one in a cell narrower than it is tall can still be named. None of the + table cell is held to half its own shorter side, though its cell, which the report does not + know, may lay it out narrower: one in a cell narrower than it can still be named. None of the corpus's 62 DOCX changes, and none of its 1332 notes: no corpus document holds either case. - **A DOCX export ends and turns a drawing's outline as the page strokes it.** An outline was written with no cap and no join, which Word 16 and LibreOffice draw with flat ends and round diff --git a/core/src/main/java/com/demcha/compose/document/layout/NodeDefinitionSupport.java b/core/src/main/java/com/demcha/compose/document/layout/NodeDefinitionSupport.java index f7483f5a3..636f7f073 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/NodeDefinitionSupport.java +++ b/core/src/main/java/com/demcha/compose/document/layout/NodeDefinitionSupport.java @@ -636,8 +636,9 @@ public static List emitTableFragments(PreparedNode pr : null))); // Composed cells: dispatch each prepared child's fragments // at the cell's absolute table-local position. The child's - // FragmentPlacement is constructed from the cell's width, - // height, and position relative to the placement path. + // FragmentPlacement is constructed from the cell's box, its + // position relative to the placement path, and the child's own + // size (emitComposedCellFragments). List rowCells = layout.rows().get(rowIndex); for (TableResolvedCell cell : rowCells) { int columnIndex = locateCellColumn(layout, rowIndex, cell); @@ -739,8 +740,17 @@ private static List emitComposedCellFragments( // quantities, which is what keeps the reserved row and the drawn content // the same size. DocumentInsets contentMargin = child.node().margin(); - double childWidth = Math.max(0.0, + // A composite child is given the cell's inner box to lay itself out in, as a row slot gives + // one its slot, and so is text, which aligns in the width it is given: a page number + // measures as wide as its digits, and set right it stands at the cell's right edge. A + // leaf of a size of its own is laid out at the width it measured, as the flow and a row + // slot place one. Given the cell's width, a picture fitted to 100pt in a 120pt column was + // drawn in a box 112pt wide, and a 20pt circle as an ellipse 112pt wide. + double innerWidth = Math.max(0.0, cellWidth - padding.horizontal() - contentMargin.horizontal()); + double childWidth = child.isComposite() || alignsInTheWidthItIsGiven(child.node()) + ? innerWidth + : Math.min(innerWidth, Math.max(0.0, child.measureResult().width())); double childHeight = Math.max(0.0, child.measureResult().height()); // Where in the cell's box the child sits. Follows resolveTextLines on // the vertical axis: same default (centre, from @@ -749,8 +759,9 @@ private static List emitComposedCellFragments( // cell land on the same line. // // Two deliberate differences. The horizontal half of the anchor is not - // read: a composed child is laid out at the cell's inner width, so - // there is no slack across for it to sit in, where a text line has its + // read: a composite child and text lay themselves out in the cell's + // inner box, and a leaf of a size of its own starts at the cell's + // content edge, as a row slot starts one, where a text line has its // own measured width. And slack is clamped at zero rather than allowed // to go negative — an over-tall child keeps starting at the bottom and // overflowing upward instead of being pushed below its own cell. No @@ -817,6 +828,15 @@ private static List emitComposedCellFragments( return translated; } + /** + * Whether a leaf sets its text in the width it is given rather than drawing at a size of its + * own: a paragraph, a list, a page number and a page reference, each by its alignment. + */ + private static boolean alignsInTheWidthItIsGiven(DocumentNode node) { + return node instanceof ParagraphNode || node instanceof ListNode + || node instanceof PageReferenceNode || node instanceof PageFieldNode; + } + private static List emitDecorationFragment(Color fillColor, Stroke stroke, double cornerRadius, diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 6a52f0faf..7493f6b03 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -1189,8 +1189,8 @@ named in the report: anchor points at none, and a page reference to it is a fixed number. A gradient fill, unequal corners (drawn at the largest radius; corners whose radii differ only past half the box's shorter side, where the page holds them, are drawn alike and not named — a shape - composed in a table cell, laid out at the cell's width, is held to half its height, so one in - a cell narrower than it is tall can still be named) and a line's dash pattern (drawn solid) + composed in a table cell is held to half its own shorter side, so one in a cell narrower than + it can still be named) and a line's dash pattern (drawn solid) are not carried either. A rule — a line or a thin bar written as a paragraph border — keeps its anchor; its note names a lost outline entry, caps (a border ends flat) or a bar's link, a line's link being diff --git a/docs/recipes/tables.md b/docs/recipes/tables.md index 2d04e0e72..8c0ef09e4 100644 --- a/docs/recipes/tables.md +++ b/docs/recipes/tables.md @@ -82,6 +82,13 @@ paragraphs in the cell, not just the section's background. The row stays atomic: a composed cell does not split across a page break, so keep the child shorter than one page's content area. +Text in a composed cell aligns in the cell's inner width, and a section, +a row or any other composite lays itself out in the cell's inner box. A +picture, a shape, a barcode or any +other node of a size of its own keeps that size, held to the cell's +inner width where it is wider, and starts at the cell's left content +edge: the cell's horizontal anchor is not read for composed content. + ## Row span — merge a cell vertically diff --git a/knowledge/claims/index.json b/knowledge/claims/index.json index d926de4f4..466dd3b81 100644 --- a/knowledge/claims/index.json +++ b/knowledge/claims/index.json @@ -202,7 +202,7 @@ "TableBuilder.headerRow": [ { "page": "docs/recipes/tables.md", - "line": 207, + "line": 214, "heading": "Repeated header on page break", "surface": "authoring", "stability": "stable" @@ -211,7 +211,7 @@ "TableBuilder.repeatHeader": [ { "page": "docs/recipes/tables.md", - "line": 206, + "line": 213, "heading": "Repeated header on page break", "surface": "authoring", "stability": "stable" @@ -220,7 +220,7 @@ "TableBuilder.totalRow": [ { "page": "docs/recipes/tables.md", - "line": 169, + "line": 176, "heading": "Totals row — bold + subtle fill", "surface": "authoring", "stability": "stable" @@ -229,7 +229,7 @@ "TableBuilder.zebra": [ { "page": "docs/recipes/tables.md", - "line": 128, + "line": 135, "heading": "Zebra — alternating row fills", "surface": "authoring", "stability": "stable" @@ -275,21 +275,21 @@ "table.repeat-header-across-pages": [ { "page": "docs/recipes/tables.md", - "line": 205, + "line": 212, "heading": "Repeated header on page break" } ], "table.row-span": [ { "page": "docs/recipes/tables.md", - "line": 87, + "line": 94, "heading": "Row span — merge a cell vertically" } ], "table.zebra-striping": [ { "page": "docs/recipes/tables.md", - "line": 127, + "line": 134, "heading": "Zebra — alternating row fills" } ] @@ -370,7 +370,7 @@ "table.explicit-row-style-beats-zebra": [ { "page": "docs/recipes/tables.md", - "line": 129, + "line": 136, "heading": "Zebra — alternating row fills", "proof": "test:TableBuilderZebraAndTotalsTest" } @@ -378,7 +378,7 @@ "table.header-repeats-on-every-continuation-page": [ { "page": "docs/recipes/tables.md", - "line": 208, + "line": 215, "heading": "Repeated header on page break", "proof": "test:TableBuilderRepeatHeaderTest" } @@ -386,7 +386,7 @@ "table.repeat-header-defaults-to-zero": [ { "page": "docs/recipes/tables.md", - "line": 209, + "line": 216, "heading": "Repeated header on page break", "proof": "test:TableBuilderRepeatHeaderTest" } @@ -394,7 +394,7 @@ "table.spanning-cell-height-is-sum-of-covered-rows": [ { "page": "docs/recipes/tables.md", - "line": 88, + "line": 95, "heading": "Row span — merge a cell vertically", "proof": "test:TableBuilderRowSpanTest" } diff --git a/knowledge/proofs/index.json b/knowledge/proofs/index.json index d36d65204..6750ba063 100644 --- a/knowledge/proofs/index.json +++ b/knowledge/proofs/index.json @@ -121,14 +121,14 @@ "kind": "behavior", "value": "table.header-repeats-on-every-continuation-page", "page": "docs/recipes/tables.md", - "line": 208, + "line": 215, "heading": "Repeated header on page break" }, { "kind": "behavior", "value": "table.repeat-header-defaults-to-zero", "page": "docs/recipes/tables.md", - "line": 209, + "line": 216, "heading": "Repeated header on page break" } ] @@ -141,7 +141,7 @@ "kind": "behavior", "value": "table.spanning-cell-height-is-sum-of-covered-rows", "page": "docs/recipes/tables.md", - "line": 88, + "line": 95, "heading": "Row span — merge a cell vertically" } ] @@ -154,7 +154,7 @@ "kind": "behavior", "value": "table.explicit-row-style-beats-zebra", "page": "docs/recipes/tables.md", - "line": 129, + "line": 136, "heading": "Zebra — alternating row fills" } ] diff --git a/qa/src/test/java/com/demcha/compose/document/table/ComposedCellLeafWidthTest.java b/qa/src/test/java/com/demcha/compose/document/table/ComposedCellLeafWidthTest.java new file mode 100644 index 000000000..d186de5e4 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/table/ComposedCellLeafWidthTest.java @@ -0,0 +1,166 @@ +package com.demcha.compose.document.table; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.EllipseBuilder; +import com.demcha.compose.document.dsl.ImageBuilder; +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.dsl.ShapeBuilder; +import com.demcha.compose.document.image.DocumentImageData; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.payloads.EllipseFragmentPayload; +import com.demcha.compose.document.layout.payloads.ImageFragmentPayload; +import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload; +import com.demcha.compose.document.layout.payloads.ShapeFragmentPayload; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.PageFieldKind; +import com.demcha.compose.document.node.PageFieldNode; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.testing.VisualTestOutputs; +import org.junit.jupiter.api.Test; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.nio.file.Files; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * A leaf of a size of its own composed in a table cell is laid out at the width it measured, as + * the flow and a row slot lay one out; text in the width it aligns in. + * + *

The cell gave every leaf its inner width. One of a size of its own was stretched to it: in a + * 120pt column padded 4pt, a picture fitted to 100pt was drawn in a box 112pt wide, 112×56 rather + * than 100×50, and a 20pt circle as an ellipse 112pt wide. The row was sized from what the leaf + * measured all along, so only the width was wrong. Text keeps the cell's inner width, which it + * aligns in, and a composite is placed in the cell's inner box as before.

+ */ +class ComposedCellLeafWidthTest { + + private static final double TOLERANCE = 0.01; + + /** The 120pt column less the default 4pt cell padding on each side. */ + private static final double INNER = 112; + + @Test + void aFittedPictureIsDrawnInItsOwnBox() throws Exception { + PlacedFragment picture = single(layout(new ImageBuilder().source(picture(200, 100)).fitToBounds(100, 100).build()), + ImageFragmentPayload.class); + + assertThat(picture.width()).as("its own width, not the cell's").isCloseTo(100, within(TOLERANCE)); + assertThat(picture.height()).isCloseTo(100, within(TOLERANCE)); + } + + @Test + void aCircleStaysACircle() { + PlacedFragment circle = single(layout(new EllipseBuilder().circle(20).fillColor(DocumentColor.BLACK).build()), + EllipseFragmentPayload.class); + + assertThat(circle.width()).isCloseTo(20, within(TOLERANCE)); + assertThat(circle.height()).isCloseTo(20, within(TOLERANCE)); + } + + @Test + void aLeafStartsAtTheCellsContentEdge() { + LayoutGraph graph = layout(new ShapeBuilder().size(30, 10).fillColor(DocumentColor.BLACK).build()); + PlacedFragment shape = single(graph, ShapeFragmentPayload.class); + + assertThat(shape.width()).isCloseTo(30, within(TOLERANCE)); + assertThat(shape.x()).as("the page margin and the cell's padding, as before") + .isCloseTo(72 + 4, within(TOLERANCE)); + } + + @Test + void aShapeWiderThanItsCellIsHeldToIt() { + // Measured wider than the content box, it was laid out at the cell's width before too. + LayoutGraph graph = layout(new ShapeBuilder().size(INNER + 20, 10).fillColor(DocumentColor.BLACK).build()); + + assertThat(single(graph, ShapeFragmentPayload.class).width()).isCloseTo(INNER, within(TOLERANCE)); + } + + @Test + void aCentredParagraphKeepsTheCellsWidth() { + DocumentNode centred = new ParagraphBuilder().text("Centred").align(TextAlign.CENTER).build(); + + assertThat(single(layout(centred), ParagraphFragmentPayload.class).width()) + .as("text that aligns in the width it is given measures that width") + .isCloseTo(INNER, within(TOLERANCE)); + } + + @Test + void aLeftAlignedParagraphKeepsTheCellsWidth() { + DocumentNode left = new ParagraphBuilder().text("Left").build(); + + assertThat(single(layout(left), ParagraphFragmentPayload.class).width()) + .as("text is set in the width it is given, though it measures as wide as its words") + .isCloseTo(INNER, within(TOLERANCE)); + } + + @Test + void aPageNumberSetRightStandsAtTheCellsRightEdge() { + // It measures as wide as its digits, whatever its alignment. + DocumentNode number = new PageFieldNode("Number", PageFieldKind.NUMBER, DocumentTextStyle.DEFAULT, + TextAlign.RIGHT, DocumentInsets.zero(), DocumentInsets.zero()); + + assertThat(single(layout(number), ParagraphFragmentPayload.class).width()) + .as("the width it aligns in").isCloseTo(INNER, within(TOLERANCE)); + } + + @Test + void theCellsRenderedForInspection() throws Exception { + try (DocumentSession session = session()) { + session.pageFlow(page -> page + .addTable(table -> table + .columns(DocumentTableColumn.fixed(120), DocumentTableColumn.fixed(100)) + .rowCells(DocumentTableCell.node(new ImageBuilder().source(picture(200, 100)) + .fitToBounds(100, 100).build()), + DocumentTableCell.text("Picture")) + .rowCells(DocumentTableCell.node(new EllipseBuilder().circle(20) + .fillColor(DocumentColor.BLACK).build()), + DocumentTableCell.text("Circle")))); + byte[] pdf = session.toPdfBytes(); + Files.write(VisualTestOutputs.preparePdf("composed-cell-leaf-width", "table"), pdf); + assertThat(new String(pdf, 0, 5, java.nio.charset.StandardCharsets.US_ASCII)).isEqualTo("%PDF-"); + } + } + + private static LayoutGraph layout(DocumentNode composed) { + try (DocumentSession session = session()) { + session.pageFlow(page -> page.addTable(table -> table + .columns(DocumentTableColumn.fixed(120), DocumentTableColumn.fixed(100)) + .rowCells(DocumentTableCell.node(composed), DocumentTableCell.text("Beside")))); + return session.layoutGraph(); + } + } + + private static DocumentSession session() { + return GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(72)).create(); + } + + private static PlacedFragment single(LayoutGraph graph, Class payload) { + List found = fragments(graph, payload); + assertThat(found).hasSize(1); + return found.get(0); + } + + private static List fragments(LayoutGraph graph, Class payload) { + return graph.fragments().stream().filter(fragment -> payload.isInstance(fragment.payload())).toList(); + } + + private static DocumentImageData picture(int width, int height) { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + ByteArrayOutputStream png = new ByteArrayOutputStream(); + try { + javax.imageio.ImageIO.write(image, "png", png); + } catch (java.io.IOException failure) { + throw new java.io.UncheckedIOException(failure); + } + return DocumentImageData.fromBytes(png.toByteArray()); + } +} 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 457c107fe..1ee72708a 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 @@ -4382,15 +4382,15 @@ private static boolean roundsCornersUnequally(com.demcha.compose.document.layout /** * Whether a shape or a shape container composed in a table cell, which has no fragment of its - * own to read them off, rounds its corners by radii that differ. A shape there is drawn as wide - * as its cell lays it out and as tall as it is, so its radii are held to half its height, past - * which none is drawn: a radius that differs on the page is named, and one that differs only - * past half a cell narrower than the shape is tall is named too. An outline with neither a - * fill nor a stroke that shows draws nothing. + * own to read them off, rounds its corners by radii that differ. A shape there is drawn at its + * own size, held to its cell's inner width where that is narrower, so its radii are held to half + * its shorter side, past which none is drawn: a radius that differs on the page is named, and + * one that differs only past half a cell narrower than the shape is named too. An outline with + * neither a fill nor a stroke that shows draws nothing. */ private static boolean cornersDiffer(DocumentNode node) { if (node instanceof com.demcha.compose.document.node.ShapeNode shape) { - return DocxDrawings.roundsUnequally(shape.cornerRadius(), Double.POSITIVE_INFINITY, shape.height()); + return DocxDrawings.roundsUnequally(shape.cornerRadius(), shape.width(), shape.height()); } return node instanceof ShapeContainerNode container && container.outline() instanceof com.demcha.compose.document.style.ShapeOutline.RoundedRectanglePerCorner corners @@ -14396,8 +14396,9 @@ private record CellRule(DocxLayoutMetrics.CellBox box, double start, double end) /** * A cell's waiting drawings that draw a rule of a node's kind and thickness, in their order: - * a level line as long as the node's, or a bar. A bar is as wide as the layout makes it — a - * lone one fills its cell — and so is a line running to the end of whatever width it is given. + * a level line as long as the node's, or a bar. A bar is as wide as the layout makes it — its + * own width, held to its cell's — and so is a line running to the end of whatever width it is + * given. */ private static List drawingsOf(List waiting, DocumentNode node, DocxRules.Rule rule) { List drawings = new ArrayList<>(); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java index 1503bcec5..f24ca2bbf 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java @@ -270,18 +270,18 @@ void cornersWhoseRadiiDifferOnlyPastHalfTheShorterSideAreNotNamed() throws Excep } @Test - void aShapeComposedInACellNamesTheCornersItIsDrawnWithAtTheCellsWidth() throws Exception { - // Laid out 20 wide, it is drawn as wide as its cell: 10 and 12 then stay 10 and 12, and - // Word draws all four at 12. The one 20 tall holds both at 10, however wide its cell. + void aShapeComposedInACellNamesTheCornersItIsDrawnWithAtItsOwnWidth() throws Exception { + // Laid out 40 wide, it holds 10 and 12 at 20, so they stay 10 and 12, and Word draws all + // four at 12. The one 20 wide holds both at 10, however wide its cell, as the page draws it. DocumentCornerRadius corners = DocumentCornerRadius.of(10, 12, 10, 12); DocxExportReport report = reportOf(page -> page .addTable(table -> table.columns(DocumentTableColumn.fixed(100), DocumentTableColumn.fixed(100)) - .rowCells(DocumentTableCell.node(new ShapeBuilder().size(20, 40).fillColor(INK) + .rowCells(DocumentTableCell.node(new ShapeBuilder().size(40, 40).fillColor(INK) .cornerRadius(corners).build()), - DocumentTableCell.node(new ShapeBuilder().size(20, 20).fillColor(INK) + DocumentTableCell.node(new ShapeBuilder().size(20, 40).fillColor(INK) .cornerRadius(corners).build())))); - assertThat(detailOf(report, "ShapeNode")).as("the tall one's, and the short one names nothing") + assertThat(detailOf(report, "ShapeNode")).as("the wide one's, and the narrow one names nothing") .startsWith("composed in a table cell, whose drawing is its table's").endsWith(CORNERS); }