From 971b87baed3cf51e8382354ef5b29178a054d45e Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 8 Aug 2026 21:35:45 +0100 Subject: [PATCH] fix(docx): size an image the way it was asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drawn box came from the node's literal width and height, and fell back to a hardcoded 100 x 100 pt when either was missing. So an image sized only by scale, or by one dimension with the other implied by its aspect ratio, came out at a size nothing had asked for. The box now comes from resolveImageDimensions, the rule layout already applies, clamp to the page's content width included. fitMode was not read at all, which left CONTAIN and COVER behaving as STRETCH. CONTAIN is embedded at its fitted size, needing no clip because it is inside the box already. COVER fills the box and the overflow is cropped out of the picture source, centred, since Word has no clip for an inline picture — the geometry the PPTX backend expresses for the same reason. Every picture was declared PNG whatever its bytes were, so a JPEG went into the package announced as something else. The signature decides now, and the call moved onto the addPicture overload POI still supports. An image with no size and no scale is drawn at its intrinsic size rather than at the old 100 x 100, which is what layout does with it. --- CHANGELOG.md | 14 ++ README.md | 2 +- .../architecture/backend-capability-matrix.md | 2 +- render-docx/README.md | 10 +- .../semantic/docx/DocxSemanticBackend.java | 126 ++++++++++- .../semantic/docx/DocxImageFitTest.java | 196 ++++++++++++++++++ 6 files changed, 337 insertions(+), 13 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxImageFitTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aaab684..a66450bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,20 @@ follow semantic versioning; release dates are ISO 8601. position at fault, where before it was drawn wrong. That is the rule the layout pipeline already applied, so a document the PDF backend refuses is no longer one DOCX accepts. +- **A DOCX image is the size it asked for, in the shape it asked for.** The drawn box came + from the node's literal `width` and `height` and fell back to a hardcoded 100 × 100 pt + when either was absent, so an image sized only by `scale` — or by one dimension with the + other implied by its aspect ratio — came out at a size nothing had asked for. The box now + comes from `NodeDefinitionSupport.resolveImageDimensions`, the rule layout already applies, + clamp to the page's content width included. + + `fitMode` was not read at all, which left `CONTAIN` and `COVER` behaving as `STRETCH`. + `CONTAIN` is embedded at its fitted size, which needs no clipping because it is inside the + box already; `COVER` fills the box and the overflow is cropped out of the picture source, + centred, since Word has no clip for an inline picture — the same geometry the PPTX backend + expresses. And the picture type is read from the image's signature instead of every picture + being declared PNG, which is what a JPEG was announced as. + The grid itself is resolved by `TableGrid`, extracted from the layout pipeline so both it and the backend answer from one implementation. It is `@Internal`: a backend seam, not a public promise. diff --git a/README.md b/README.md index dddd5d7c..e5c66710 100644 --- a/README.md +++ b/README.md @@ -335,7 +335,7 @@ See [CONTRIBUTING](./CONTRIBUTING.md) for the branch-routing table and the full | Format | Status | Notes | |---|---|---| | PDF | Production | Fixed-layout backend on PDFBox 3.0. Full DSL coverage. | -| DOCX | Partial | Semantic export via Apache POI — paragraphs, lists, block images, tables and metadata. Word owns the flow, so drawing nodes (`shape`, `line`, `ellipse`, `barcode`) are dropped, one logged warning per kind. **Hyperlinks, bookmarks and headers/footers are not implemented**, table `colSpan`/`rowSpan` is not applied, and image fit modes are ignored — see [render-docx](./render-docx/README.md#what-it-maps-and-what-it-does-not). | +| DOCX | Partial | Semantic export via Apache POI — paragraphs, lists, block images, tables and metadata. Word owns the flow, so drawing nodes (`shape`, `line`, `ellipse`, `barcode`) are dropped, one logged warning per kind. Tables keep their `colSpan`/`rowSpan` and images their fit mode; **hyperlinks, bookmarks and headers/footers are not implemented**, and table fill and border paint are dropped — see [render-docx](./render-docx/README.md#what-it-maps-and-what-it-does-not). | | PPTX | Beta | Fixed-layout export via Apache POI from the same resolved layout — one page per editable slide with native shapes and text frames; clipped regions land as pixel-exact pictures. First shipped in 2.1, marked `@Beta` while the API shape settles. | ### Text & internationalization diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index f07e3573..c8c47ebf 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -67,7 +67,7 @@ Payload records live in `core` under | Linear gradient fill (`DocumentPaint`) | ✅ `PdfShadingSupport` | ✅ `PptxGradientFill` (native `gradFill`; explicit-axis endpoints approximate to the angle) | ❌ | | Radial gradient fill (`DocumentPaint`) | ✅ `PdfShadingSupport` | ⚠️ `PptxGradientFill` (`circle` path shade — DrawingML cannot express radius-to-farthest-corner exactly) | ❌ | | Gradient strokes | ✅ `PdfPathPainter` (pattern stroking colour) | ✅ `PptxGradientFill` (native `ln`/`gradFill`) | ❌ | -| Image — STRETCH / CONTAIN / COVER fit (`ImageFragmentPayload`) | ✅ `PdfImageFragmentRenderHandler` | ✅ `PptxImageFragmentRenderHandler` (COVER via the picture source crop) | ⚠️ `DocxSemanticBackend.writeImage` (the picture is embedded at the node's width/height; `fitMode` and `scale` are never read, so CONTAIN and COVER behave as STRETCH, a node with neither width nor height falls back to 100×100 pt, and every picture is declared `PICTURE_TYPE_PNG`) | +| Image — STRETCH / CONTAIN / COVER fit (`ImageFragmentPayload`) | ✅ `PdfImageFragmentRenderHandler` | ✅ `PptxImageFragmentRenderHandler` (COVER via the picture source crop) | ✅ `DocxSemanticBackend.writeImage` (the box comes from `NodeDefinitionSupport.resolveImageDimensions`, the same rule layout applies to `width` / `height` / `scale` and the content-width clamp; CONTAIN is embedded at its fitted size, COVER via the picture source crop as in PPTX, and the picture type is read from the bytes) | | Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (ZXing raster) | ✅ `PptxBarcodeFragmentRenderHandler` (identical ZXing raster) | ❌ | | Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves: `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; fill and border paint are not applied, and a composed cell writes paragraphs and their wrappers only — one built from an image or a list lands empty) | | 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 | diff --git a/render-docx/README.md b/render-docx/README.md index 5690a0c0..7b45593b 100644 --- a/render-docx/README.md +++ b/render-docx/README.md @@ -48,7 +48,12 @@ not its drawing. What maps: paragraphs, lists, block images, tables, and document metadata (title, author, subject, keywords). Run styling carries font family, size, colour, bold, italic, -underline and strikethrough, per run rather than per paragraph. +underline and strikethrough, per run rather than per paragraph. A block image is sized by +the rule layout uses — `width` / `height` / `scale`, the aspect ratio filling in whichever +is missing, and a clamp to the page's content width — and honours its fit mode: `CONTAIN` +is embedded at its fitted size, `COVER` fills the box and the overflow is cropped out of +the picture source rather than clipped, which Word has no way to express for an inline +picture. What maps only in part: @@ -58,9 +63,6 @@ What maps only in part: `DocumentTableStyle`, so a merged, styled table exports with the right shape on Word's default rules. A composed cell writes the shapes a cell can hold — paragraphs, and the wrappers around them — so one built from an image or a list still lands empty. -- **Image fit is ignored.** The picture is embedded at the node's width and height; - `CONTAIN` and `COVER` therefore behave as `STRETCH`, and an image sized only by `scale` - falls back to 100 × 100 pt. These are **not implemented** even though Word itself can express them — check the list before you promise a `.docx` to a reader: 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 6916e6c1..2712236c 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 @@ -6,8 +6,11 @@ import com.demcha.compose.document.chart.NumberFormatSpec; import com.demcha.compose.document.dsl.TableBuilder; import com.demcha.compose.document.image.DocumentImageData; +import com.demcha.compose.document.image.DocumentImageFitMode; +import com.demcha.compose.engine.components.content.ImageData; import com.demcha.compose.document.layout.DocumentGraph; import com.demcha.compose.document.layout.LayoutCanvas; +import com.demcha.compose.document.layout.NodeDefinitionSupport; import com.demcha.compose.document.layout.TableGrid; import com.demcha.compose.document.node.ChartNode; import com.demcha.compose.document.node.ContainerNode; @@ -29,14 +32,16 @@ import com.demcha.compose.document.table.DocumentTableStyle; import org.apache.poi.util.Units; import org.apache.poi.xwpf.usermodel.BreakType; -import org.apache.poi.xwpf.usermodel.Document; import org.apache.poi.xwpf.usermodel.ParagraphAlignment; import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.common.usermodel.PictureType; import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFPicture; import org.apache.poi.xwpf.usermodel.XWPFRun; import org.apache.poi.xwpf.usermodel.XWPFTable; import org.apache.poi.xwpf.usermodel.XWPFTableCell; import org.apache.poi.xwpf.usermodel.XWPFTableRow; +import org.openxmlformats.schemas.drawingml.x2006.main.CTRelativeRect; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageMar; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageSz; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr; @@ -74,6 +79,9 @@ public final class DocxSemanticBackend implements SemanticBackend { private static final double POINT_TO_TWIP = 20.0; private static final Logger LOG = LoggerFactory.getLogger(DocxSemanticBackend.class); + // The page's content width, so an image is held to the same bound layout holds it to. + // Set per export; Double.MAX_VALUE means "no canvas, so nothing to clamp against". + private double contentWidth = Double.MAX_VALUE; // One capability warning per export pass keeps the log readable when a // template uses many shape containers. Reset on every export() call so // each session sees the warning at least once. @@ -99,6 +107,7 @@ public byte[] export(DocumentGraph graph, SemanticExportContext context) throws shapeContainerWarned.set(false); chartWarned.set(false); warnedNodeKinds.clear(); + contentWidth = context.canvas() == null ? Double.MAX_VALUE : context.canvas().innerWidth(); try (XWPFDocument document = new XWPFDocument()) { applyPageGeometry(document, context.canvas()); applyOutputOptions(document, context.outputOptions()); @@ -333,6 +342,23 @@ private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node) { } } + /** + * Embeds an image at the size the node asks for, in the shape its fit mode asks for. + * + *

The box came from the node's literal {@code width} / {@code height} and fell back + * to a hardcoded 100 × 100 pt when either was absent — so an image sized only by + * {@code scale}, or by one dimension with the other implied by its aspect ratio, came + * out at a size nothing had asked for. {@link NodeDefinitionSupport#resolveImageDimensions} + * is the rule the layout pipeline applies for exactly this, including the clamp to the + * page's content width, and is used here so the two agree.

+ * + *

{@code fitMode} then decides how the image sits in that box, matching the PDF + * handler: {@code CONTAIN} scales by the smaller ratio and is embedded at that size, + * which needs no clipping because it is inside the box already; {@code COVER} scales by + * the larger and crops the overflow away in source space through {@code a:srcRect}, + * centred, the way the PPTX backend expresses the same geometry; {@code STRETCH} fills + * the box.

+ */ private void writeImage(XWPFDocument document, ImageNode node) throws Exception { DocumentImageData data = node.imageData(); byte[] bytes = data.bytes() @@ -342,17 +368,103 @@ private void writeImage(XWPFDocument document, ImageNode node) throws Exception if (bytes.length == 0) { return; } + ImageData resolved = NodeDefinitionSupport.toImageData(node.imageData()); + double sourceWidth = Math.max(1, resolved.getMetadata().width()); + double sourceHeight = Math.max(1, resolved.getMetadata().height()); + NodeDefinitionSupport.ImageDimensions box = + NodeDefinitionSupport.resolveImageDimensions(node, contentWidth); + + DocumentImageFitMode fitMode = + node.fitMode() == null ? DocumentImageFitMode.STRETCH : node.fitMode(); + double drawWidth = box.width(); + double drawHeight = box.height(); + if (fitMode == DocumentImageFitMode.CONTAIN) { + double scale = Math.min(box.width() / sourceWidth, box.height() / sourceHeight); + drawWidth = sourceWidth * scale; + drawHeight = sourceHeight * scale; + } + XWPFParagraph para = document.createParagraph(); XWPFRun run = para.createRun(); try (InputStream stream = new java.io.ByteArrayInputStream(bytes)) { - int width = node.width() == null ? 100 : (int) Math.round(node.width()); - int height = node.height() == null ? 100 : (int) Math.round(node.height()); - run.addPicture(stream, - Document.PICTURE_TYPE_PNG, + XWPFPicture picture = run.addPicture(stream, + pictureType(bytes), "image", - Units.toEMU(width), - Units.toEMU(height)); + Units.toEMU(drawWidth), + Units.toEMU(drawHeight)); + if (fitMode == DocumentImageFitMode.COVER) { + applyCoverCrop(picture, sourceWidth, sourceHeight, box); + } + } + } + + /** + * Crops a {@code COVER} image to its box, centred, in source space. + * + *

Word has no clip for an inline picture, so the overflow the PDF backend clips away + * is removed from the source instead: the picture is placed at the box's size and + * {@code a:srcRect} names the fraction of each edge that is not shown.

+ */ + private void applyCoverCrop(XWPFPicture picture, double sourceWidth, double sourceHeight, + NodeDefinitionSupport.ImageDimensions box) { + double scale = Math.max(box.width() / sourceWidth, box.height() / sourceHeight); + double horizontal = (sourceWidth * scale - box.width()) / (sourceWidth * scale) / 2.0; + double vertical = (sourceHeight * scale - box.height()) / (sourceHeight * scale) / 2.0; + CTRelativeRect srcRect = picture.getCTPicture().getBlipFill().addNewSrcRect(); + srcRect.setL(toThousandthPercent(horizontal)); + srcRect.setR(toThousandthPercent(horizontal)); + srcRect.setT(toThousandthPercent(vertical)); + srcRect.setB(toThousandthPercent(vertical)); + } + + /** A crop fraction as the per-100000 integer DrawingML stores. */ + private static int toThousandthPercent(double fraction) { + if (Double.isNaN(fraction) || fraction <= 0.0) { + return 0; + } + return (int) Math.round(Math.min(fraction, 0.5) * 100_000); + } + + /** + * The picture type the bytes actually are. + * + *

Every image was declared {@code PNG} regardless of its content, so a JPEG went into + * the package announced as something it is not. The signature is read instead; a format + * with no signature here keeps the old answer and says so once.

+ */ + private PictureType pictureType(byte[] bytes) { + if (startsWith(bytes, 0x89, 0x50, 0x4E, 0x47)) { + return PictureType.PNG; + } + if (startsWith(bytes, 0xFF, 0xD8, 0xFF)) { + return PictureType.JPEG; + } + if (startsWith(bytes, 0x47, 0x49, 0x46)) { + return PictureType.GIF; + } + if (startsWith(bytes, 0x42, 0x4D)) { + return PictureType.BMP; + } + if (startsWith(bytes, 0x49, 0x49, 0x2A, 0x00) || startsWith(bytes, 0x4D, 0x4D, 0x00, 0x2A)) { + return PictureType.TIFF; + } + if (warnedNodeKinds.add("image-signature")) { + LOG.warn("DocxSemanticBackend: image bytes carry no recognised signature — declaring PNG, " + + "which is what Word will try to decode them as"); + } + return PictureType.PNG; + } + + private static boolean startsWith(byte[] bytes, int... signature) { + if (bytes.length < signature.length) { + return false; + } + for (int i = 0; i < signature.length; i++) { + if ((bytes[i] & 0xFF) != signature[i]) { + return false; + } } + return true; } private byte[] readBytes(Path path) { diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxImageFitTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxImageFitTest.java new file mode 100644 index 00000000..43a5cb39 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxImageFitTest.java @@ -0,0 +1,196 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.image.DocumentImageData; +import com.demcha.compose.document.image.DocumentImageFitMode; +import com.demcha.compose.document.style.DocumentInsets; +import org.apache.poi.util.Units; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFPicture; +import org.junit.jupiter.api.Test; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * Image sizing and fit in the DOCX semantic backend. + * + *

The drawn box used to come from the node's literal width and height and fell back to a + * hardcoded 100 × 100 pt when either was missing, so an image sized only by {@code scale}, or + * by one dimension with the other implied, came out at a size nothing asked for. {@code + * fitMode} was not read at all, which made {@code CONTAIN} and {@code COVER} behave as + * {@code STRETCH} — the aspect ratio was not preserved and {@code COVER} did not crop. And + * every image was declared PNG whatever its bytes were.

+ * + *

The source is a 40 × 20 image, so its 2:1 ratio makes each fit mode produce a different + * answer in a square box and the three cannot pass for one another.

+ */ +class DocxImageFitTest { + + private static final int SOURCE_WIDTH = 40; + private static final int SOURCE_HEIGHT = 20; + + @Test + void anImageSizedOnlyByScaleUsesItsIntrinsicSize() throws Exception { + // The 100 x 100 fallback ignored both the intrinsic size and the scale. + XWPFPicture picture = firstPicture(image -> image.scale(0.5)); + + assertThat(emuWidth(picture)).isEqualTo(Units.toEMU(SOURCE_WIDTH * 0.5)); + assertThat(emuHeight(picture)).isEqualTo(Units.toEMU(SOURCE_HEIGHT * 0.5)); + } + + @Test + void oneDimensionImpliesTheOtherFromTheAspectRatio() throws Exception { + XWPFPicture picture = firstPicture(image -> image.width(80)); + + assertThat(emuWidth(picture)).isEqualTo(Units.toEMU(80.0)); + assertThat(emuHeight(picture)).isEqualTo(Units.toEMU(40.0)); + } + + @Test + void containKeepsTheAspectRatioInsideTheBox() throws Exception { + // A square box around a 2:1 image: CONTAIN fits the width and leaves height over. + XWPFPicture picture = firstPicture(image -> image + .fitToBounds(100, 100) + .fitMode(DocumentImageFitMode.CONTAIN)); + + assertThat(emuWidth(picture)).isEqualTo(Units.toEMU(100.0)); + assertThat(emuHeight(picture)).isEqualTo(Units.toEMU(50.0)); + assertThat(srcRect(picture)).isNull(); + } + + @Test + void coverFillsTheBoxAndCropsTheOverflowInSourceSpace() throws Exception { + XWPFPicture picture = firstPicture(image -> image + .fitToBounds(100, 100) + .fitMode(DocumentImageFitMode.COVER)); + + // The picture occupies the whole box; what does not fit is cropped, not squashed. + assertThat(emuWidth(picture)).isEqualTo(Units.toEMU(100.0)); + assertThat(emuHeight(picture)).isEqualTo(Units.toEMU(100.0)); + + // Covering a square box with a 2:1 source scales by height, making the drawn width + // twice the box — so half the source width is hidden, a quarter on each side. + var crop = srcRect(picture); + assertThat(crop).isNotNull(); + assertThat(crop.getL()).isEqualTo(25_000); + assertThat(crop.getR()).isEqualTo(25_000); + assertThat(crop.getT()).isEqualTo(0); + assertThat(crop.getB()).isEqualTo(0); + } + + @Test + void coverCropsTheOtherAxisWhenTheSourceIsTall() throws Exception { + // The crop is computed per axis, and a wide source only ever exercises one of them. + XWPFPicture picture = firstPicture(imageBytes("png", SOURCE_HEIGHT, SOURCE_WIDTH), + image -> image.fitToBounds(100, 100).fitMode(DocumentImageFitMode.COVER)); + + var crop = srcRect(picture); + assertThat(crop).isNotNull(); + assertThat(crop.getT()).isEqualTo(25_000); + assertThat(crop.getB()).isEqualTo(25_000); + assertThat(crop.getL()).isEqualTo(0); + assertThat(crop.getR()).isEqualTo(0); + } + + @Test + void stretchFillsTheBoxExactlyAndCropsNothing() throws Exception { + XWPFPicture picture = firstPicture(image -> image + .fitToBounds(100, 100) + .fitMode(DocumentImageFitMode.STRETCH)); + + assertThat(emuWidth(picture)).isEqualTo(Units.toEMU(100.0)); + assertThat(emuHeight(picture)).isEqualTo(Units.toEMU(100.0)); + // Filling the box distorts on purpose; nothing is hidden. + assertThat(srcRect(picture)).isNull(); + } + + @Test + void aJpegIsDeclaredAsAJpegRatherThanAsPng() throws Exception { + XWPFPicture png = firstPicture(imageBytes("png"), image -> image.width(40)); + XWPFPicture jpeg = firstPicture(imageBytes("jpg"), image -> image.width(40)); + + assertThat(png.getPictureData().suggestFileExtension()).isEqualTo("png"); + assertThat(jpeg.getPictureData().suggestFileExtension()).isEqualTo("jpeg"); + } + + @Test + void anImageWiderThanThePageIsHeldToItsContentWidth() throws Exception { + // The same clamp layout applies, so the two do not disagree about the drawn size. + XWPFPicture picture = firstPicture(image -> image.width(10_000)); + + double contentWidth = 595 - 2 * 36; + assertThat(emuWidth(picture)).isEqualTo(Units.toEMU(contentWidth)); + assertThat((double) emuHeight(picture)) + .isEqualTo(Units.toEMU(contentWidth / 2.0), within(2.0 * Units.EMU_PER_POINT)); + } + + private static org.openxmlformats.schemas.drawingml.x2006.main.CTRelativeRect srcRect( + XWPFPicture picture) { + return picture.getCTPicture().getBlipFill().getSrcRect(); + } + + private static long emuWidth(XWPFPicture picture) { + return picture.getCTPicture().getSpPr().getXfrm().getExt().getCx(); + } + + private static long emuHeight(XWPFPicture picture) { + return picture.getCTPicture().getSpPr().getXfrm().getExt().getCy(); + } + + private static XWPFPicture firstPicture( + Consumer spec) throws Exception { + return firstPicture(imageBytes("png"), spec); + } + + private static XWPFPicture firstPicture( + byte[] bytes, Consumer spec) + throws Exception { + byte[] docx; + try (DocumentSession session = GraphCompose.document() + .pageSize(595, 842) + .margin(DocumentInsets.of(36)) + .create()) { + session.dsl().pageFlow().name("Flow") + .addImage(image -> { + image.source(DocumentImageData.fromBytes(bytes)); + spec.accept(image); + }) + .build(); + docx = session.export(new DocxSemanticBackend()); + } + try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) { + List pictures = document.getParagraphs().stream() + .flatMap(paragraph -> paragraph.getRuns().stream()) + .flatMap(run -> run.getEmbeddedPictures().stream()) + .toList(); + assertThat(pictures).hasSize(1); + return pictures.get(0); + } + } + + private static byte[] imageBytes(String format) throws Exception { + return imageBytes(format, SOURCE_WIDTH, SOURCE_HEIGHT); + } + + private static byte[] imageBytes(String format, int width, int height) throws Exception { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + for (int x = 0; x < width; x++) { + for (int y = 0; y < height; y++) { + image.setRGB(x, y, (x * 6) << 16 | (y * 12) << 8 | 0x40); + } + } + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + ImageIO.write(image, format, out); + return out.toByteArray(); + } + } +}