diff --git a/CHANGELOG.md b/CHANGELOG.md index 6febe295..3c1cd196 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,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(); + } + } +}