diff --git a/CHANGELOG.md b/CHANGELOG.md index 2217acb4f..066559177 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A barcode reaches Word, and scans.** The DOCX export dropped a `BarcodeNode` with the + geometry-only nodes, so a receipt or a shipping label lost the code a reader scans. It is + now a PNG picture of the same ZXing matrix the PDF and PPTX backends draw + (`BarcodeMatrices`), one pixel a cell in the symbol's two colours with their alpha, encoded + in memory, at the node's size, with its margin as the space around it, its `anchor` as a + bookmark and its data as the picture's description. Decoded back from the picture, a QR + code and a Code 128 return their data. The data is part of the picture rather than + editable in Word, and the report says so (`APPROXIMATED`), naming a link or a transform + on the barcode as not carried. Outside the body — in a page zone — a barcode is still + skipped. The PDF backend comes in at compile scope for this; see Packaging. + - **A table cell's alignment reaches Word.** The DOCX export read neither half of a cell's `textAnchor`: every column a template right-aligns or centres — amounts, quantities, totals — came out flush left, and since Word's default is the top of the cell while the @@ -591,9 +602,9 @@ follow semantic versioning; release dates are ISO 8601. publishes one — but the DOCX module declared it at test scope, so an application depending on `graph-compose-core` + `graph-compose-render-docx` failed at `create()` with `MissingBackendException` before it could export anything, and the class javadoc said the - opposite. The PDF backend is now a `runtime` dependency of the DOCX module, the way the - PPTX module already brings it: nothing compiles against it through this module, and an - application that declared it by hand resolves the same version. Checked with three + opposite. The PDF backend is now a dependency of the DOCX module at compile scope, the way + the PPTX module already brings it — the DOCX module draws a barcode with its matrix encoder + — and an application that declared it by hand resolves the same version. Checked with three consumer applications built outside the reactor against the installed artifacts — DOCX alone, PDF + DOCX, PDF + PPTX + DOCX: each exports, and each resolves one Apache POI (5.5.1, `poi-ooxml-lite`, never `-full`), one XMLBeans (5.3.0) and one PDFBox (3.0.8), so diff --git a/ROADMAP.md b/ROADMAP.md index 0e42c855b..3143b4c2a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,7 +97,7 @@ Committed internal direction for the post-2.0 line: refactors, scale work, and t Not committed. Reflects current thinking; priorities may shift based on user feedback and adoption signals. - **PPTX beyond beta.** Graduate the fixed-layout PPTX backend from `@Beta` to stable and close the remaining fidelity gaps — true vector clipping instead of the raster fallback ([#413](https://github.com/DemchaAV/GraphCompose/issues/413)), exact numeric dash arrays, and distinct per-corner radii. -- **A strict mode for DOCX.** Dropping a node the format cannot carry (`shape`, `line`, `ellipse`, `barcode`) now warns, so the loss is at least visible in the log. What is missing is the option to refuse: a flag that fails the export instead of silently producing a document with content gone. +- **A strict mode for DOCX.** Dropping a node the format cannot carry (`shape`, `line`, `ellipse`) now warns, so the loss is at least visible in the log. What is missing is the option to refuse: a flag that fails the export instead of silently producing a document with content gone. - **Block-level alignment for fixed-size flow children.** Paths, images, layer stacks, shape containers and barcodes currently left-align in a flow; centring one means wrapping it in a full-width `ShapeContainer` just to use its CENTER anchor. Add a per-node horizontal align (left / centre / right — the `margin: auto` / `align(center)` analogue) so a fixed box can place itself in the flow directly. Surfaced by the v1.8 SVG icon-gallery and feature-catalog work. - **Backend-neutral layout measurement.** Decouple measurement from PDFBox-specific resources so non-PDF backends do not pull PDFBox into the dependency graph. - **DOCX maturity.** Either expand DOCX coverage toward PDF parity, or move DOCX behind an explicitly experimental flag. diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index bcddd23df..91c90fb5e 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -39,11 +39,11 @@ SemanticExportContext)` and `exportSections(List)`, so `renderToImages` and the raster-slide builder option are not questions it can be asked. Drawing nodes are a different case: `ShapeNode`, `LineNode`, `EllipseNode`, -`PolygonNode`, `PathNode` and `BarcodeNode` all reach +`PolygonNode` and `PathNode` all reach `DocxSemanticBackend.writeNode` and are dropped there with a logged warning, and Word can express floating shapes, pictures and gradient fills. Those rows are ❌ — no implementation, no current plan — not -`n/a`. +`n/a`. A `BarcodeNode` reaches it too and is written as a picture. The PPTX *semantic* skeleton (`PptxSemanticBackend`, slide-safe node validation + manifest, writes no file) is out of scope for this matrix. @@ -71,7 +71,7 @@ Payload records live in `core` under | 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 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` (vector: the ZXing bit matrix filled as merged rectangles) | ✅ `PptxBarcodeFragmentRenderHandler` (native freeforms: the same ZXing bit matrix as merged rectangles) | ❌ | +| Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (vector: the ZXing bit matrix filled as merged rectangles) | ✅ `PptxBarcodeFragmentRenderHandler` (native freeforms: the same ZXing bit matrix as merged rectangles) | ⚠️ `DocxSemanticBackend.writeBarcode` (a PNG picture of the same ZXing bit matrix through `BarcodeMatrices`, one pixel a cell, in the symbol's two colours with their alpha and at the node's size, its data as the picture's description; it scans, but its data is part of the picture rather than editable, reported `APPROXIMATED`, which also names a link or a transform on it as not carried; an `anchor` is a bookmark on its paragraph; in a page zone it is skipped) | | 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; the cell's fill maps to `w:shd` and its stroke to `w:tcBorders`; the cascaded `textAnchor` maps to `w:vAlign` on every cell and to `w:jc` on a text cell's paragraph, with the engine's default — the vertical middle, on the left, or on the right for a right-to-left cell — and `DEFAULT` at the bottom left, as the renderer draws it; a composed cell is written by the same writers that write its node anywhere, so one built from an image, a list or a table carries it — a nested table is a real `w:tbl` taking the width of the column it sits in, which is the column's rather than the one the page gives it, since the layout reports a composed cell's content under the owner's path; a fill's opacity is dropped since `w:shd` is opaque; Word re-paginates, so the export states where the layout breaks: every row the layout placed is `w:cantSplit`, `repeatHeader(n)` rows are `w:tblHeader` and keep with the row under them, and a row of blocks is kept whole the same way) | | 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 | | 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 | ⚠️ omitted: the rail is resolved fixed-layout geometry and `DocxSemanticBackend` consumes the semantic tree, never a `LayoutGraph`. A timeline's **content** exports in full — entries, titles, meta and bodies — and the export does not throw; only the drawn line is absent. Marker shapes are geometry and may be omitted for the same reason | diff --git a/docs/architecture/package-map.md b/docs/architecture/package-map.md index 718f5167a..67602848d 100644 --- a/docs/architecture/package-map.md +++ b/docs/architecture/package-map.md @@ -24,7 +24,7 @@ ships them differs. | `graph-compose-core` | The lean engine: `com.demcha.compose`, the canonical `document.*` authoring surface (`api` / `dsl` / `node` / `style` / `table` / `snapshot`), `document.showcase` (`FontShowcase`), the `document.backend.fixed` SPI seam, the public `document.backend.fixed.pdf.options` records, `document.layout`, `font.*`, and the internal `engine.*` foundation. | | `graph-compose-render-pdf` | The PDFBox backend: `document.backend.fixed.pdf.**` (the `PdfFixedLayoutBackend` impl + handlers) and the `engine.render.pdf.**` render tree. Registers the PDF `FixedLayoutBackendProvider` / `FontMetricsProvider`. | | `graph-compose-render-pptx` | The POI XSLF backend: `document.backend.fixed.pptx.**` (the `PptxFixedLayoutBackend` impl + handlers), registering the `"pptx"` `FixedLayoutBackendProvider`. Also carries the older `document.backend.semantic.pptx` manifest exporter. Depends on `graph-compose-render-pdf` for shared font measurement and the clip raster pass. | -| `graph-compose-render-docx` | The POI semantic exporter — `document.backend.semantic.docx`. Brings `graph-compose-render-pdf` at runtime scope, because opening a session needs its font-metrics provider. | +| `graph-compose-render-docx` | The POI semantic exporter — `document.backend.semantic.docx`. Brings `graph-compose-render-pdf` at compile scope, because opening a session needs its font-metrics provider and a barcode is drawn with its matrix encoder. | | `graph-compose-templates` | The built-in preset families — `document.templates.**`. | | `graph-compose-testing` | Consumer test support — `com.demcha.compose.testing.**`. | | `graph-compose` | Back-compat wrapper: an empty jar over `graph-compose-core` + `graph-compose-render-pdf`. | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 7a6356e56..4509f92e4 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -390,8 +390,14 @@ tint it was flattened to. Recorded, like the other two. ## What is skipped -Lines, ellipses, standalone shapes, and barcodes are **silently skipped** -— they are pure fixed-layout geometry with no semantic equivalent. +Lines, ellipses and standalone shapes are **skipped**, and the report names +each one — they are pure fixed-layout geometry with no semantic equivalent. + +A barcode in the body is not skipped: it exports as a picture of the symbol at +its size, the same matrix the PDF draws, so it scans, with its data as the +picture's description. Its data is part of the picture — changing it means +exporting again — and the report says so, and names a link or a transform on it +as not carried. In a page zone a barcode is still skipped. The text header and footer slots, watermarks, and protection options are also ignored by the current exporter. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3c13c94bf..9dbad8664 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -25,13 +25,15 @@ bullet `•` (U+2022) **is** in WinAnsi, but the larger black circle `●` Full detail and the coverage table: [font coverage and glyph fallback](font-coverage.md). -## DOCX export is missing shapes, lines, ellipses, or barcodes +## DOCX export is missing shapes, lines, or ellipses **Cause.** The DOCX backend (`DocxSemanticBackend`, Apache POI) is a **semantic** exporter. POI cannot express fixed-layout graphics, so -`shape`, `line`, `ellipse`, and `barcode` nodes are **dropped silently**, -and `ShapeContainerNode` clipping / `DocumentTransform` rotation + scale -fall back to inline content with a one-time capability warning. +`shape`, `line`, and `ellipse` nodes are **dropped** — logged once per kind and +named in the export report — and `ShapeContainerNode` clipping / +`DocumentTransform` rotation + scale fall back to inline content with a +one-time capability warning. A `barcode` is not dropped: it is written as a +picture of the symbol, which scans but whose data is not editable in Word. **Fix.** Export those documents to **PDF** (the full-fidelity backend). Use DOCX only for paragraph / list / table / image / section content. diff --git a/render-docx/README.md b/render-docx/README.md index f5546131c..4d9cb73d2 100644 --- a/render-docx/README.md +++ b/render-docx/README.md @@ -12,9 +12,10 @@ Add it (at compile scope) only when you export `.docx`. It is **not** included b **From 2.5.0 it is sufficient on its own.** Opening a `DocumentSession` resolves a `FontMetricsProvider` so text can be measured, and `graph-compose-render-pdf` is the only -artifact that publishes one — so this module brings it at `runtime` scope. A classpath of -`graph-compose-core` + `graph-compose-render-docx` opens a session and exports `.docx`; -you never compile against the PDF backend through it. +artifact that publishes one — so this module brings it, at compile scope, as the PPTX +module does: a barcode is written as a picture of the same matrix the PDF backend draws, +through that module's encoder. A classpath of `graph-compose-core` + +`graph-compose-render-docx` opens a session and exports `.docx`. **On 2.4.x and earlier it was not.** Those versions declared the PDF backend at test scope only, and core + render-docx failed at `create()` with `MissingBackendException` before any diff --git a/render-docx/pom.xml b/render-docx/pom.xml index df84b82c7..8cc18bbe8 100644 --- a/render-docx/pom.xml +++ b/render-docx/pom.xml @@ -87,17 +87,17 @@ poi-ooxml ${poi.version} - + there is no working DOCX export without it. Compile scope, as the PPTX backend + carries it: a barcode is written as a picture of the same ZXing matrix the PDF + and PPTX backends draw, through render-pdf's BarcodeMatrices, so the three + cannot encode one symbol differently. --> io.github.demchaav graph-compose-render-pdf ${project.version} - runtime diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxBarcodePictures.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxBarcodePictures.java new file mode 100644 index 000000000..3078792f2 --- /dev/null +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxBarcodePictures.java @@ -0,0 +1,81 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.backend.fixed.pdf.handlers.BarcodeMatrices; +import com.demcha.compose.engine.components.content.barcode.BarcodeData; +import com.google.zxing.common.BitMatrix; + +import javax.imageio.ImageIO; +import javax.imageio.ImageWriter; +import javax.imageio.stream.MemoryCacheImageOutputStream; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.awt.image.IndexColorModel; +import java.awt.image.WritableRaster; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +/** + * A barcode as the picture Word can hold. + * + *

Word has no barcode, and drawing the symbol from shapes would make every bar a separate + * object a reader can knock out of place. So the symbol is a picture: the same ZXing matrix + * the PDF and PPTX backends draw ({@link BarcodeMatrices}), one pixel per cell — the matrix is + * already at least two cells per point and two hundred a side — in two colours, the foreground + * for a dark cell and the background for the rest, each with its own alpha. The data is not + * editable in Word; the picture scans as the page's does.

+ * + *

The PNG is encoded in memory. {@code ImageIO.write} to a stream caches through a temporary + * file by default, which is the per-barcode cost the PDF backend's barcodes were measured + * paying and stopped paying when they became vectors.

+ */ +final class DocxBarcodePictures { + + private DocxBarcodePictures() { + } + + /** + * Encodes a barcode as a PNG sized for a box of the given size. + * + * @param data the symbol, its colours and its quiet zone + * @param width the box width in points + * @param height the box height in points + * @return the PNG bytes, one pixel per matrix cell + * @throws IOException when ZXing cannot encode the content in that symbology + */ + static byte[] png(BarcodeData data, double width, double height) throws IOException { + BitMatrix matrix = BarcodeMatrices.encode(data, (int) width, (int) height); + BufferedImage image = new BufferedImage(matrix.getWidth(), matrix.getHeight(), + BufferedImage.TYPE_BYTE_BINARY, twoColours(data.getBackground(), data.getForeground())); + WritableRaster raster = image.getRaster(); + int[] row = new int[matrix.getWidth()]; + for (int y = 0; y < matrix.getHeight(); y++) { + for (int x = 0; x < row.length; x++) { + row[x] = matrix.get(x, y) ? 1 : 0; + } + raster.setPixels(0, y, row.length, 1, row); + } + return encode(image); + } + + /** Index 0 is the background, index 1 the foreground, alpha included. */ + private static IndexColorModel twoColours(Color background, Color foreground) { + return new IndexColorModel(1, 2, + new byte[]{(byte) background.getRed(), (byte) foreground.getRed()}, + new byte[]{(byte) background.getGreen(), (byte) foreground.getGreen()}, + new byte[]{(byte) background.getBlue(), (byte) foreground.getBlue()}, + new byte[]{(byte) background.getAlpha(), (byte) foreground.getAlpha()}); + } + + private static byte[] encode(BufferedImage image) throws IOException { + ImageWriter writer = ImageIO.getImageWritersByFormatName("png").next(); + try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + MemoryCacheImageOutputStream out = new MemoryCacheImageOutputStream(bytes)) { + writer.setOutput(out); + writer.write(image); + out.flush(); + return bytes.toByteArray(); + } finally { + writer.dispose(); + } + } +} 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 97e61f904..b509cc6e9 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 @@ -143,8 +143,9 @@ *

Dependencies: this backend ships in * {@code io.github.demchaav:graph-compose-render-docx}, which brings * {@code org.apache.poi:poi-ooxml} transitively, and - * {@code graph-compose-render-pdf} at runtime scope — opening a session resolves - * the font-metrics provider only that module publishes. Adding that one artifact + * {@code graph-compose-render-pdf} — opening a session resolves the font-metrics + * provider only that module publishes, and a barcode is drawn with its matrix encoder, + * so it is carried at compile scope, as the PPTX module carries it. Adding that one artifact * to {@code graph-compose-core} is all a DOCX consumer needs.

* *

Threads: an instance holds the state of the export it is running — the spacing @@ -1010,6 +1011,9 @@ private static String blockAnchorOf(DocumentNode node) { if (node instanceof ImageNode image) { return image.anchor(); } + if (node instanceof com.demcha.compose.document.node.BarcodeNode barcode) { + return barcode.anchor(); + } return null; } @@ -1154,6 +1158,8 @@ private void writeNodeContent(XWPFDocument document, DocumentNode node) throws E writePageReference(document, reference); } else if (node instanceof ImageNode image) { writeImage(document, image); + } else if (node instanceof com.demcha.compose.document.node.BarcodeNode barcode) { + writeBarcode(document, barcode); } else if (node instanceof TableNode table) { writeTableWithItsOwnSpacing(document, table); } else if (node instanceof SpacerNode spacer) { @@ -1179,8 +1185,8 @@ private void writeNodeContent(XWPFDocument document, DocumentNode node) throws E // as a one-cell table carrying both, instead of disappearing. writeContainerChildren(document, node); } else { - // Geometry-only node kinds (line, ellipse, shape, path, polygon, - // barcode) have no semantic Word analogue. Warn once per kind so a + // Geometry-only node kinds (line, ellipse, shape, path, polygon) + // have no semantic Word analogue. Warn once per kind so a // dropped chart-line or icon is visible in the log instead of // silently missing; authors needing pixel-perfect output use the // PDF fixed-layout backend. @@ -2912,6 +2918,44 @@ private void writeImage(XWPFDocument document, ImageNode node) throws Exception } } + /** + * Writes a barcode as a picture of the symbol at the size the page draws it. + * + *

It was dropped with the geometry-only nodes, so a receipt or a shipping label lost + * the code a reader scans. The picture carries the same matrix the page draws (see + * {@link DocxBarcodePictures}); what it does not carry is the data, which in Word is part + * of a picture rather than something to edit, so the report says so — and says so of a + * link or a transform on it too, which the picture does not carry either.

+ * + *

A symbol drawn in two transparent colours is still written: it holds its space on the + * page, and an anchor on it has to land somewhere.

+ */ + private void writeBarcode(XWPFDocument document, com.demcha.compose.document.node.BarcodeNode node) + throws Exception { + com.demcha.compose.engine.components.content.barcode.BarcodeData data = + NodeDefinitionSupport.toBarcodeData(node.barcodeOptions()); + byte[] png = DocxBarcodePictures.png(data, node.width(), node.height()); + XWPFParagraph para = newBodyParagraph(document); + applyVerticalSpacing(para, node); + XWPFRun run = para.createRun(); + try (InputStream stream = new java.io.ByteArrayInputStream(png)) { + run.addPicture(stream, PictureType.PNG, "barcode", + 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()); + 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"); + if (node.linkTarget() != null) { + message.append("; its link is not carried"); + } + if (node.transform() != null && !node.transform().isIdentity()) { + message.append("; its transform is not carried, so it is drawn upright at its size"); + } + report.add(DocxExportReport.Severity.APPROXIMATED, "barcode", layout.pathOf(node), message.toString()); + } + /** * Crops a {@code COVER} image to its box, centred, in source space. * diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBarcodeTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBarcodeTest.java new file mode 100644 index 000000000..4c3f13f8e --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBarcodeTest.java @@ -0,0 +1,187 @@ +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.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.engine.components.content.barcode.BarcodeData; +import com.demcha.compose.engine.components.content.barcode.BarcodeType; +import com.google.zxing.BinaryBitmap; +import com.google.zxing.MultiFormatReader; +import com.google.zxing.RGBLuminanceSource; +import com.google.zxing.common.HybridBinarizer; +import org.apache.poi.util.Units; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFPicture; +import org.apache.poi.xwpf.usermodel.XWPFPictureData; +import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBookmark; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A barcode reaches Word as a picture that scans. + * + *

It was dropped with the geometry-only nodes, so a receipt or a shipping label lost the + * code a reader scans. The picture is the matrix the PDF and PPTX backends draw.

+ * + * @author Artem Demchyshyn + */ +class DocxBarcodeTest { + + @Test + void aQrCodeIsAPictureAtItsSizeThatDecodesToItsData() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page + .addBarcode(b -> b.qrCode().data("GC-2026-001").size(80, 80)))) { + XWPFPicture picture = onlyPicture(document); + + assertThat(picture.getCTPicture().getSpPr().getXfrm().getExt().getCx()).isEqualTo(Units.toEMU(80)); + assertThat(picture.getCTPicture().getSpPr().getXfrm().getExt().getCy()).isEqualTo(Units.toEMU(80)); + assertThat(decode(picture.getPictureData())).isEqualTo("GC-2026-001"); + } + } + + @Test + void aLinearBarcodeDecodesToItsDataToo() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page + .addBarcode(b -> b.code128().data("INV-0042").size(160, 40)))) { + assertThat(decode(onlyPicture(document).getPictureData())).isEqualTo("INV-0042"); + } + } + + @Test + void theSymbolIsDrawnInItsOwnColours() throws Exception { + DocumentColor ink = DocumentColor.rgb(26, 86, 148); + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page + .addBarcode(b -> b.qrCode().data("colour").foreground(ink).size(60, 60)))) { + BufferedImage image = ImageIO.read(new ByteArrayInputStream(onlyPicture(document).getPictureData().getData())); + boolean inkFound = false; + for (int y = 0; y < image.getHeight() && !inkFound; y++) { + for (int x = 0; x < image.getWidth() && !inkFound; x++) { + inkFound = (image.getRGB(x, y) & 0xFFFFFF) == 0x1A5694; + } + } + assertThat(inkFound).isTrue(); + } + } + + @Test + void theReportSaysItIsAPictureAndNothingIsDropped() throws Exception { + AtomicReference report = new AtomicReference<>(); + try (DocumentSession session = GraphCompose.document().pageSize(595, 842) + .margin(DocumentInsets.of(36)).create()) { + session.pageFlow(page -> page.addBarcode(b -> b.qrCode().data("x").size(50, 50))); + session.export(new DocxSemanticBackend(report::set)); + } + + assertThat(report.get().bySubject()).containsKey("barcode"); + assertThat(report.get().count(DocxExportReport.Severity.APPROXIMATED)).isEqualTo(1); + assertThat(report.get().count(DocxExportReport.Severity.DROPPED)).isZero(); + } + + @Test + void aBarcodesAnchorIsABookmarkOnItsParagraph() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page + .addBarcode(b -> b.qrCode().data("x").size(50, 50).anchor("ticket")))) { + List bookmarks = document.getParagraphs().get(0).getCTP().getBookmarkStartList(); + + assertThat(bookmarks).extracting(CTBookmark::getName).containsExactly("ticket"); + } + } + + @Test + void theMarginAroundABarcodeIsTheSpaceAroundItsParagraph() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page + .addParagraph(p -> p.text("Above")) + .addBarcode(b -> b.qrCode().data("x").size(50, 50).margin(DocumentInsets.top(12))))) { + var spacing = document.getParagraphs().get(1).getCTP().getPPr().getSpacing(); + + assertThat(DocxTwips.of(spacing.getBefore())).isEqualTo(12 * 20L); + } + } + + @Test + void thePictureIsTheMatrixThePageDrawsOnePixelACell() throws Exception { + BarcodeData data = BarcodeData.of("cells", BarcodeType.CODE_128); + var matrix = com.demcha.compose.document.backend.fixed.pdf.handlers.BarcodeMatrices.encode(data, 150, 40); + BufferedImage image = ImageIO.read(new ByteArrayInputStream(DocxBarcodePictures.png(data, 150, 40))); + + assertThat(image.getWidth()).isEqualTo(matrix.getWidth()); + assertThat(image.getHeight()).isEqualTo(matrix.getHeight()); + for (int x = 0; x < matrix.getWidth(); x++) { + boolean dark = (image.getRGB(x, matrix.getHeight() / 2) & 0xFFFFFF) == 0; + assertThat(dark).as("cell " + x).isEqualTo(matrix.get(x, matrix.getHeight() / 2)); + } + } + + @Test + void aTranslucentBackgroundKeepsItsAlpha() throws Exception { + BarcodeData data = BarcodeData.of("alpha", BarcodeType.QR_CODE, + java.awt.Color.BLACK, new java.awt.Color(255, 255, 255, 0)); + BufferedImage image = ImageIO.read(new ByteArrayInputStream(DocxBarcodePictures.png(data, 60, 60))); + + assertThat(image.getRGB(0, 0) >>> 24).as("the quiet zone shows what is under it").isZero(); + } + + @Test + void thePicturesDescriptionIsItsData() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page + .addBarcode(b -> b.qrCode().data("GC-2026-001").size(60, 60)))) { + var docPr = document.getParagraphs().get(0).getRuns().get(0).getCTR() + .getDrawingArray(0).getInlineArray(0).getDocPr(); + + assertThat(docPr.getDescr()).isEqualTo("GC-2026-001"); + } + } + + @Test + void aTransparentBarcodeStillHoldsItsPlaceAndItsAnchor() throws Exception { + DocumentColor clear = DocumentColor.of(new java.awt.Color(0, 0, 0, 0)); + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page + .addBarcode(b -> b.qrCode().data("x").size(50, 50).foreground(clear).background(clear) + .anchor("hidden")))) { + XWPFPicture picture = onlyPicture(document); + + assertThat(picture.getCTPicture().getSpPr().getXfrm().getExt().getCy()).isEqualTo(Units.toEMU(50)); + assertThat(document.getParagraphs().get(0).getCTP().getBookmarkStartList()) + .extracting(CTBookmark::getName) + .containsExactly("hidden"); + } + } + + @Test + void aLinkOnABarcodeIsReportedAsNotCarried() throws Exception { + AtomicReference report = new AtomicReference<>(); + try (DocumentSession session = GraphCompose.document().pageSize(595, 842) + .margin(DocumentInsets.of(36)).create()) { + session.pageFlow(page -> page + .addParagraph(p -> p.text("Target").anchor("target")) + .addBarcode(b -> b.qrCode().data("x").size(50, 50).linkTo("target"))); + session.export(new DocxSemanticBackend(report::set)); + } + + assertThat(report.get().bySubject().get("barcode").get(0).detail()).contains("link is not carried"); + } + + private static XWPFPicture onlyPicture(XWPFDocument document) { + List pictures = document.getParagraphs().stream() + .flatMap(p -> p.getRuns().stream()) + .flatMap(r -> r.getEmbeddedPictures().stream()) + .toList(); + assertThat(pictures).hasSize(1); + return pictures.get(0); + } + + private static String decode(XWPFPictureData data) throws Exception { + BufferedImage image = ImageIO.read(new ByteArrayInputStream(data.getData())); + int[] pixels = image.getRGB(0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth()); + return new MultiFormatReader().decode(new BinaryBitmap(new HybridBinarizer( + new RGBLuminanceSource(image.getWidth(), image.getHeight(), pixels)))).getText(); + } +}