Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/backend-capability-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
10 changes: 6 additions & 4 deletions render-docx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -74,6 +79,9 @@
public final class DocxSemanticBackend implements SemanticBackend<byte[]> {
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.
Expand All @@ -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());
Expand Down Expand Up @@ -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.
*
* <p>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.</p>
*
* <p>{@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.</p>
*/
private void writeImage(XWPFDocument document, ImageNode node) throws Exception {
DocumentImageData data = node.imageData();
byte[] bytes = data.bytes()
Expand All @@ -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.
*
* <p>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.</p>
*/
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.
*
* <p>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.</p>
*/
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) {
Expand Down
Loading
Loading