Skip to content

feat(vector-shapes): shadow effects, group transform composition, and shape-clipped image rendering (SD-2999 and SD-3033)#3713

Open
luccas-harbour wants to merge 51 commits into
mainfrom
luccas/sd-2999-feature-vector-shapes
Open

feat(vector-shapes): shadow effects, group transform composition, and shape-clipped image rendering (SD-2999 and SD-3033)#3713
luccas-harbour wants to merge 51 commits into
mainfrom
luccas/sd-2999-feature-vector-shapes

Conversation

@luccas-harbour

Copy link
Copy Markdown
Contributor

Summary

Major improvements to vector shape rendering fidelity, covering outer shadow effects, shape-group transform composition, pictures clipped to preset shape geometry, and the anchoring/layout corrections needed to position this artwork where Word puts it.

Outer shadow effects on vector shapes

  • New ShapeEffects / ShapeOuterShadowEffect contract types, with shared paint-extent math in contracts/src/shape-effects.ts (resolveOuterShadowOffset, getOuterShadowStdDeviation, getOuterShadowPaintExtent) so the painter and measurer agree on how much room a shadow needs.
  • DomPainter renders shadows with SVG filters (feDropShadow for filled shapes; a blur/offset/flood/composite chain that clones the path for no-fill outline-only shadows).
  • Effect extents grow to fit the shadow paint area, and the resolved-layout version signature now includes the vector effect extent so shadow changes invalidate correctly.

Shape-group transforms

  • ShapeGroupTransform gains rotation / flipH / flipV; group transforms are applied as container transforms so nested groups compose correctly (child-local orientation, nested affine orientation, rotation around the visible group box).
  • The DOM measurer measures transformed group bounds, preserves effect extents, and avoids double-applying measured rotation.
  • Grouped picture children get correct clipping and edge-stroke paint room.

Pictures clipped to preset shape geometry

  • Images masked by a preset shape (prstGeom on pic:spPr) now carry a shapeClipPath plus an objectFit through the converter → layout adapter → painter pipeline, for inline, anchored, and grouped images alike.
  • Stretched fills are mapped to the right fit: cover for shape-masked stretches, fill for srcRect-clipped stretches, with negative srcRect values (which Word treats as canvas growth, not crop) handled explicitly.
  • Layout-bridge now dirties inline image masks and rendered drawing changes so mask edits repaint.

Anchoring and layout corrections

  • Paragraphs holding page-relative anchors (explicit or fallback) now participate in wrap; pre-registered anchors resolve against the active section, and stale pre-registered wraps are dropped.
  • Anchored objects on section-marker paragraphs (sectPr-only empty paragraphs) are preserved: the marker paragraph is visually suppressed while its anchors register at the marker origin, with Word-compatible offsets for marker image groups.
  • Header/footer: anchored wrapNone media render as page-level absolute overlays, non-page-relative overlays position from the container origin, ordinary wrapNone media are measured, and anchored media are excluded from decoration normalization.
  • Page-background (behind-doc) decorations stack by authored OOXML z-index instead of document order.

Text in shapes

  • Textbox paragraph spacing (before/after) is carried per logical paragraph in ShapeTextContent.paragraphs and rendered in vector shape text, with a new isParagraphBoundary flag distinguishing paragraph breaks from intra-paragraph <w:br>.

Preset geometry

  • Arrow shapes regenerated with Word-compatible geometry; roundRect paths generated in target coordinate space.
  • Stroke width preserved on large-coordinate-scale shapes via vector-effect: non-scaling-stroke.

Testing

  • New regression suite painters/dom/src/renderer-shape-regressions.test.ts (~1,000 lines) plus expanded unit coverage across contracts, layout-engine, layout-bridge, layout-resolved, measuring, layout-adapter, converter helpers, and header/footer session management.

@luccas-harbour luccas-harbour requested a review from a team as a code owner June 11, 2026 15:19
@linear-code

linear-code Bot commented Jun 11, 2026

Copy link
Copy Markdown

SD-2999

SD-3033

@github-actions

Copy link
Copy Markdown
Contributor

I verified the changed handler files against my knowledge of ECMA-376 (the live ecma-spec MCP tools were denied permission in this environment, and the clarifying question was dismissed — so this review is from spec knowledge, not live schema lookups; I'll flag that where it matters).

Here's what I checked across the OOXML-facing changes:

  • extractOuterShadowEffect (vector-shape-helpers.js:348) — reads a:effectLsta:outerShdw with blurRad, dist, dir. These are all valid CT_OuterShadowEffect attributes. Units are handled correctly: blurRad/dist are EMUs (→ emuToPixels), and dir is a 60,000ths-of-a-degree positive fixed angle (→ rotToDegrees). ✓
  • Shape-group transform (encode-image-node-helpers.js) — wpg:wgp, grpSpPr, a:xfrm with off/ext/chOff/chExt/rot/flipH/flipV, and child element names wsp/pic/grpSp. All valid; matched local-name-agnostically. ✓
  • Picture presentationpic:blipFill, a:stretch/a:fillRect, a:srcRect, pic:spPra:prstGeom[@prst], a:blip[@r:embed], a:alphaModFix[@amt]. All valid, and gating ellipse → shape clip-path is a legitimate (if partial) preset read. ✓
  • extractBodyPrPropertiesanchor, lIns/tIns/rIns/bIns, wrap, spcFirstLastPara are all real CT_TextBodyProperties attributes. The inset defaults (91440 EMU horizontal, 45720 EMU vertical) and anchor/wrap defaults match the spec. ✓

One issue stands out:

Status: FAIL

spcFirstLastPara default is invertedtextbox-content-helpers.js:144-150

When spcFirstLastPara is absent from wps:bodyPr, the code computes honorFirstLast = true, so it applies before-spacing on the first paragraph and after-spacing on the last. But in ECMA-376 the spcFirstLastPara attribute on bodyPr defaults to false — meaning by default that leading/trailing paragraph spacing should be suppressed. The handler treats the missing-attribute case as true, which is the opposite of the spec default (the test "honors first and last paragraph spacing when spcFirstLastPara is absent" locks in this inverted behavior). See https://ooxml.dev/spec?q=bodyPr

Caveat: I couldn't confirm this against the live schema (MCP permission was denied), so please double-check the default for spcFirstLastPara before acting — if it does default to false, the absent-attribute branch should set honorFirstLast = false. Everything else in the diff's element/attribute usage looks spec-correct.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eb9f8f7388

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/layout-engine/painters/dom/src/images/image-block.ts Outdated
luccas-harbour added a commit that referenced this pull request Jun 11, 2026
ECMA-376 §21.1.2.1.1 specifies that an omitted spcFirstLastPara on bodyPr
implies false, meaning before-spacing on the first paragraph and
after-spacing on the last paragraph of a text body are suppressed by
default. The helper treated the absent attribute as true, honoring edge
spacing the spec says to drop. Honor edge spacing only when the
attribute explicitly enables it.

Addresses the OOXML spec review finding on PR #3713; verified against
the spec prose (the XSD declares no default, the prose defines it).
luccas-harbour added a commit that referenced this pull request Jun 11, 2026
createBlockImageContent only applied shapeClipPath to a caller-supplied
clip container, so standalone DrawingML image drawings rendered through
createDrawingImageElement (which passes no container) silently lost
their preset shape mask and painted rectangular. When a shape mask is
present and no container is supplied, wrap the image in its own clip
container so the mask and the srcRect crop keep separate clip-paths.

Addresses the Codex P2 review finding on PR #3713.
@luccas-harbour

Copy link
Copy Markdown
Contributor Author

I verified the changed handler files against my knowledge of ECMA-376 (the live ecma-spec MCP tools were denied permission in this environment, and the clarifying question was dismissed — so this review is from spec knowledge, not live schema lookups; I'll flag that where it matters).

Here's what I checked across the OOXML-facing changes:

  • extractOuterShadowEffect (vector-shape-helpers.js:348) — reads a:effectLsta:outerShdw with blurRad, dist, dir. These are all valid CT_OuterShadowEffect attributes. Units are handled correctly: blurRad/dist are EMUs (→ emuToPixels), and dir is a 60,000ths-of-a-degree positive fixed angle (→ rotToDegrees). ✓
  • Shape-group transform (encode-image-node-helpers.js) — wpg:wgp, grpSpPr, a:xfrm with off/ext/chOff/chExt/rot/flipH/flipV, and child element names wsp/pic/grpSp. All valid; matched local-name-agnostically. ✓
  • Picture presentationpic:blipFill, a:stretch/a:fillRect, a:srcRect, pic:spPra:prstGeom[@prst], a:blip[@r:embed], a:alphaModFix[@amt]. All valid, and gating ellipse → shape clip-path is a legitimate (if partial) preset read. ✓
  • extractBodyPrPropertiesanchor, lIns/tIns/rIns/bIns, wrap, spcFirstLastPara are all real CT_TextBodyProperties attributes. The inset defaults (91440 EMU horizontal, 45720 EMU vertical) and anchor/wrap defaults match the spec. ✓

One issue stands out:

Status: FAIL

spcFirstLastPara default is invertedtextbox-content-helpers.js:144-150

When spcFirstLastPara is absent from wps:bodyPr, the code computes honorFirstLast = true, so it applies before-spacing on the first paragraph and after-spacing on the last. But in ECMA-376 the spcFirstLastPara attribute on bodyPr defaults to false — meaning by default that leading/trailing paragraph spacing should be suppressed. The handler treats the missing-attribute case as true, which is the opposite of the spec default (the test "honors first and last paragraph spacing when spcFirstLastPara is absent" locks in this inverted behavior). See https://ooxml.dev/spec?q=bodyPr

Caveat: I couldn't confirm this against the live schema (MCP permission was denied), so please double-check the default for spcFirstLastPara before acting — if it does default to false, the absent-attribute branch should set honorFirstLast = false. Everything else in the diff's element/attribute usage looks spec-correct.

This was fixed

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@luccas-harbour luccas-harbour changed the title feat(vector-shapes): shadow effects, group transform composition, and shape-clipped image rendering (SD-2999) feat(vector-shapes): shadow effects, group transform composition, and shape-clipped image rendering (SD-2999 and SD-3033) Jun 11, 2026
When an SVG path's viewBox maps to a coordinate space more than 10x the
rendered size on either axis, the scale transform shrinks explicit strokes
to sub-pixel widths. Apply vector-effect="non-scaling-stroke" to colored
strokes in this case so shape outlines stay visible.
Expand the set of arrow presets that render from Word-expanded XML path
geometry to include bentArrow, bentUpArrow, downArrow, leftArrow,
leftRightUpArrow, leftUpArrow, quadArrow, rightArrow, upArrow, and
uturnArrow (previously only leftRightArrow and upDownArrow).

Rework the rightArrow geometry guides so the arrow head uses Word's
default proportions, and force leftUpArrow through the expanded-geometry
path even at equal width/height. Add regression tests asserting the
generated viewBox and path data for each preset.
Anchored header/footer drawings and images were shifted when normalizing
decoration positions for negative minY, pulling foreground anchored shapes
out of place. Broaden the exclusion beyond behindDoc fragments to also skip
fragments that are anchored or carry a sourceAnchor, so their original Y is
preserved.
Carry w:spacing before/after from textbox paragraphs through the layout
contract (ShapeTextParagraph + isParagraphBoundary) and paint it as
paragraph wrapper margins in the DOM painter. Honor wps:bodyPr
spcFirstLastPara to suppress first/last paragraph spacing, and only mark
paragraph-boundary line breaks so intra-paragraph w:br breaks don't
advance spacing. Normalize the new paragraphs metadata in the v1
layout-adapter.

Also refactor handleShapeGroup into reusable helpers and add nested
(wpg:grpSp) group flattening with EMU-space transform composition, so
child coordinates round only after composing parent/child transforms.
…ings

Behind-doc wrapNone drawings (including image drawings) are absolute
overlays positioned independently of the content region, so they should
keep their authored width/height rather than be scaled to fit maxWidth/
maxHeight. Extract isBehindDocOverlay and hasNegativeVerticalPosition
helpers and apply the bypass uniformly across measureImageBlock, the
image branch of measureDrawingBlock, and the vector-shape branch.

Previously only negative vertical positioning bypassed the height
constraint, so behind-doc overlays with positive page/paragraph-relative
offsets were shrunk and mispositioned.
…int room

Render grouped pictures with the same fidelity as top-level images:
preserve srcRect crop clipping, preset-geometry shape masks (e.g. ellipse
→ clip-path), and cover/fill objectFit. The DOM painter now wraps grouped
images in a clip container so the shape mask and source crop apply
independently rather than colliding.

Give grouped vector children room to paint their stroke: derive a
group-level effectExtent from child stroke overflow during import, grow
the drawing geometry/fragment by that extent, and inset group content so
edge strokes are no longer clipped. Connector line-end markers keep using
effectExtent for marker sizing and are excluded from stroke paint room.

Thread effectExtent through the converter, contract, version signature,
and the shapeGroup node attrs, and factor the shared picture-presentation
logic out of handleImageNode for reuse by grouped children.
Empty paragraphs that only carry section properties were dropped when
followed by a forced page break, discarding any page-relative images,
drawings, or tables anchored to them. Keep these marker paragraphs alive
when they own anchored objects, suppress their visible text spacing so
they stay invisible, and anchor their drawings/tables from the section's
raw top margin plus header distance to match Word's placement. Mixed
markers that also own page-relative anchors retain the effective body top
so companion lines stay aligned with their page-relative art.

The v1 paragraph converter now tags synthetic sectPr-marker paragraphs
with sectPrMarker so the layout engine can identify them.
…bsolute overlays

Header/footer anchored objects with wrap=None create no text exclusion
zone, so they should render as absolute story overlays without reserving
header/footer text-band height. Previously only full-canvas-covering
shapes were excluded from measurement; narrower wrapNone overlays (e.g.
cover-page art set to column/paragraph relativity) still inflated
pagination height and rendered clipped inside the header/footer container.

Drop the canvas-coverage heuristic in favor of a simple wrapNone check for
header/footer fragments, and paint these overlays directly on the page
element so they are not clipped by the container. Clean up page-level
decoration fragments via dedicated selectors on re-render and when the
section becomes empty.
Pictures placed inside a non-rectangular preset shape (pic:spPr/prstGeom)
were rendered as plain rectangles, ignoring the shape mask. Extract the
preset geometry into a shapeClipPath attribute on import, mark stretched
shape fills as objectFit: cover, and apply the clip-path to the image's
clip container at paint time so the picture is masked to the shape.

- super-converter: derive shapeClipPath/shouldCoverShapeStretch in
  extractPicturePresentation and thread them through the image and
  shape-group-child handlers
- image extension: add unrendered shapeClipPath and objectFit attrs
- DomPainter: apply shapeClipPath to the clip container with overflow hidden
… container origin

Header/footer absolute overlay fragments that are not page-relative were
painted at their raw fragment.y, ignoring the header/footer container
offset and landing in the wrong vertical position. Anchor these overlays
from the container origin (effectiveOffset plus the footer Y offset for
footers) while page-relative overlays keep their page-relative placement.
Reserve layout room for outer shadows so they are not clipped when the
docx-provided effectExtent is missing or too small.

- Derive a shadow paint extent from blur spread (3·stdDeviation) and the
  directional offset, in both the layout-adapter (standalone shapes and group
  children) and the DOM painter's group-child paint extent.
- Merge the shadow extent with the centered-stroke extent, taking the per-side
  maximum, and apply it to no-fill and zero-stroke shapes too.
- Extract a shared getShadowStdDeviation helper clamped at zero.
Layer behindDoc and header/footer wrapNone decorations within the page-background
story by their OOXML z-order instead of DOM insertion order, while keeping the
whole story at CSS z-index 0 so high OOXML z-indices never lift wrapNone shapes
above body content.

- Add getPageBackgroundDecorationZOrder to derive a stable z-order from
  behindDoc state and normalized OOXML z-index, offsetting non-behindDoc
  overlays above the behindDoc background tier.
- Add insertPageBackgroundDecoration to insert each decoration in z-order among
  the leading background-decoration run, tolerating stale nodes from patch
  renders.
ECMA-376 §21.1.2.1.1 specifies that an omitted spcFirstLastPara on bodyPr
implies false, meaning before-spacing on the first paragraph and
after-spacing on the last paragraph of a text body are suppressed by
default. The helper treated the absent attribute as true, honoring edge
spacing the spec says to drop. Honor edge spacing only when the
attribute explicitly enables it.

Addresses the OOXML spec review finding on PR #3713; verified against
the spec prose (the XSD declares no default, the prose defines it).
createBlockImageContent only applied shapeClipPath to a caller-supplied
clip container, so standalone DrawingML image drawings rendered through
createDrawingImageElement (which passes no container) silently lost
their preset shape mask and painted rectangular. When a shape mask is
present and no container is supplied, wrap the image in its own clip
container so the mask and the srcRect crop keep separate clip-paths.

Addresses the Codex P2 review finding on PR #3713.
- Add the optional effects field to TextboxDrawing; versionSignature and
  layout-bridge diff read effects on the VectorShapeDrawing |
  TextboxDrawing union, and textbox shapes carry the same spPr effect
  list as plain vector shapes.
- Fix the applyShapeEffects parameter type to the existing
  ShapeTextDrawingWithEffects alias.
- Guard the group rotation read with optional chaining; the normalized
  rotation check already implies groupTransform exists, but tsc cannot
  narrow through it.
Anchored wrapNone header/footer drawings are painted as page-level
overlays with pointer-events: none, which made their content (e.g.
footer textboxes) unclickable even while editing the footer. Keep them
inert in body mode for Word parity, but enable pointer events when the
section's session is active, and teach the input manager to treat
clicks on data-header-footer-overlay-section fragments like behindDoc
fragments so the click falls through to the active editor instead of
exiting the session.
When a shape group's transform lacks explicit dimensions, fall back to
the block geometry minus effect extents instead of the fragment
geometry, which includes the extents and skewed the transform origin.
@luccas-harbour luccas-harbour force-pushed the luccas/sd-2999-feature-vector-shapes branch from 971112a to 0511538 Compare June 11, 2026 20:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants