Skip to content

perf(docx): resolve an exported image once instead of twice - #555

Merged
DemchaAV merged 4 commits into
developfrom
fix/docx-resolve-image-once
Aug 14, 2026
Merged

perf(docx): resolve an exported image once instead of twice#555
DemchaAV merged 4 commits into
developfrom
fix/docx-resolve-image-once

Conversation

@DemchaAV

Copy link
Copy Markdown
Owner

Closes the remaining half of #531.

Why

DocxSemanticBackend.writeImage needs the node's data twice over — the bytes it writes and the intrinsic size it measures the fit against, then the box the frame gets — and asked for it twice:

ImageDataresolved = NodeDefinitionSupport.toImageData(node.imageData()); // onceNodeDefinitionSupport.resolveImageDimensions(node, contentWidth); // and again, inside

Resolving is not free. ImageSourceCache.fromBytes copies the byte array whole (Arrays.copyOf) and takes a SHA-256 over it, so a 10 MB image paid two full copies and two hashes on every export. A path-sourced image also announced its arrival twice in the log, which is what made the duplication visible in the first place.

The other half of #531 — a row cell dropping its paragraph alignment — was fixed in #547, where the cell walk started going through applyParagraphProperties.

What changed

resolveImageDimensions gains a three-argument overload taking the ImageData the caller already holds. The existing two-argument form resolves and delegates to it, so the layout path (ImageDefinition) is untouched and the DOCX backend is the only caller that changes.

Verification

DocxImageResolutionTest exports a path-sourced image and counts the arrival messages: one image, one resolution. Red before the fix, with the message logged twice —

messages were [Create an image from path: …probe.png, Create an image from path: …probe.png]

Counted through the log rather than through a spy on purpose: DocumentImageData is final, so there is no seam to instrument, and instrumenting production code to make a cost measurable would put test-only machinery in src/main. The arrival message is the one observable the resolution already leaves behind.

What the test cannot see directly is the copy and the hash — those have no observable of their own. It counts the resolution, and the copy and hash are what a resolution costs.

./mvnw clean verify over the full reactor — 13/13 modules, BUILD SUCCESS.

writeImage needs the node's data twice over -- the bytes it writes and
the intrinsic size it measures against, then the box the frame gets --
and asked for it twice. Resolving is not free: ImageSourceCache.fromBytes
copies the array whole and takes a SHA-256 over it, so a 10 MB image paid
two copies and two hashes on every export, and a path-sourced one
announced its arrival twice in the log.
resolveImageDimensions gains an overload taking the ImageData the caller
already holds. The existing two-argument form resolves and delegates, so
the layout path is untouched and nothing else has to change.
Counted through the log rather than through a spy: DocumentImageData is
final, so there is no seam to instrument, and the arrival message is the
one thing the resolution leaves behind that a test can see from outside.
Red before the fix with the message logged twice.
…zed it
Removing the second resolve left the larger half in place: the bytes
still came from their own path -- readAllBytes for a path source, a
defensive copy for a byte source -- while the metadata came from the
cache. That is a second read, and worse than a second read.
The source cache keys on the path alone, with no regard for what the file
has since become. So once a first render warmed it, a file rewritten
underneath gave writeImage fresh bytes off disk and the previous
version's metadata, and the document carried a picture embedded at
another image's dimensions. Nothing reported it: both halves succeeded,
they simply described different files.
Bytes now come from the ImageData that sized the frame, so the two cannot
disagree. readBytes goes with them.
An unreadable source used to leave through that method's swallowed
exception, silently. Resolving throws instead, so the catch is explicit
and keeps the export alive -- one bad image should not cost the document
-- but says what it dropped, as every other dropped node here does.
The new case pins the divergence rather than the cost: render, rewrite
the file with a differently-sized image, render again, and read the
embedded picture back. It reports 90 wide against a frame built for 40
before the fix.
@DemchaAV

Copy link
Copy Markdown
OwnerAuthor

Confirmed, and it is worse than a second read — thank you for pushing on it. I fixed the second resolve and left the larger half standing.

The divergence is real and silent.ImageSourceCache.fromPath keys on the absolute path alone, with no mtime in the key. So once a first render warms it, a file rewritten underneath gives writeImage fresh bytes from readAllBytes and the previous version's metadata from the cache. Both halves succeed; they simply describe different files, and the document carries a picture embedded at another image's dimensions with nothing reporting it.

Fixed as you suggested — bytes come from the ImageData that sized the frame:

ImageDataresolved = NodeDefinitionSupport.toImageData(node.imageData());
byte[] bytes = resolved.getBytes();

readBytes and the DocumentImageData import are gone with it.

On the silent skip: it was not a contract, it was a side effect — readBytes swallowed its exception and returned an empty array, which the length check then treated as "nothing to draw". Resolving throws instead, so the catch is now explicit. I kept it non-fatal, since one unreadable image should not cost the whole document, but it logs the drop the way every other dropped node in this backend already does rather than vanishing.

The new case pins the divergence, not the cost, which is the gap you identified in the first test: render, rewrite the file with a differently-sized image, render again, then read the embedded picture back out of the .docx. Before the fix it reports

expected: 40
but was: 90

— a 90-wide picture in a frame built for a 40-wide one. The arrival-count test stays, since the two failures are different: one is a repeated resolution, the other is two sources disagreeing.

Full reactor clean verify — 13/13, BUILD SUCCESS.

Catching around the resolver widened the silence rather than preserving
it. The old readBytes swallowed one narrow case -- a path that would not
read -- and even that was a side effect of its catch, not a contract.
Wrapping the resolver put corrupt bytes, a format with no reader, a
metadata decode that gives up and any defect in the cache on the same
footing: a picture missing from the document, reported once per export
and only for the first one.
The catch is gone. A resolution failure now reaches the caller, which is
what it did for every one of those cases before this branch touched the
method.
The case that changes is the unreadable path, and it changes for the
better: an export that quietly returns one picture short is the worst of
the available answers. Pinned by a test, so it is a decision rather than
a side effect of removing code.
@DemchaAV

Copy link
Copy Markdown
OwnerAuthor

Right on both counts, and the second one is the part I got wrong: I read the old behaviour as "this backend skips unreadable images" and preserved it, when it was a side effect of readBytes swallowing its own exception. Preserving it around the resolver then widened it enormously — corrupt bytes, a missing ImageReader, a metadata decode that gives up and any defect in the cache all became one silently missing picture, announced once per export and only for the first.

The catch is gone. A resolution failure reaches the caller, which is exactly what it did for every one of those cases before this branch touched the method.

The one case that changes is the unreadable path, and it changes for the better — a document that quietly comes back one picture short is the worst of the available answers. I pinned it with anImageThatCannotBeReadFailsTheExportRatherThanVanishing, so it reads as a decision rather than as a side effect of deleting code.

I have not introduced a typed source-read exception here. Agreed that it belongs in its own change with one policy across the backends, rather than being invented in a PR about resolving an image once.

Full reactor clean verify — 13/13, BUILD SUCCESS. Three cases in the suite now: one resolution per export, bytes and size from that same resolution, and an unreadable source failing loudly.

isInstanceOf(Exception.class) passed for any failure at all, including
one that had nothing to do with reading the source -- so the case would
have gone on green through a change that broke the export for an entirely
different reason. It names IllegalStateException and the phrase the
source read throws, matched on the fragment rather than the whole message
since the rest of it is a temp-directory path.
@DemchaAV
DemchaAV merged commit 0460af7 into developAug 14, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-resolve-image-once branch August 14, 2026 16:02
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@DemchaAV