From 792ac8ebd1f5bb99a1ab2cdcaf43b82f39190f8a Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 12 Jun 2026 11:29:42 -0300 Subject: [PATCH 01/30] fix(painter): stop double-subtracting table indent for column-anchored images The table indent is already applied to the cell x offset, so subtracting it again shifted column-relative anchored images left by the indent amount. --- .../painters/dom/src/table/renderTableCell.test.ts | 4 ++-- .../layout-engine/painters/dom/src/table/renderTableCell.ts | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts index 57f14ba8ac..4b11c34928 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -604,7 +604,7 @@ describe('renderTableCell', () => { expect(renderedLines[0]?.dataset.blockId).toBe('para-after-anchor'); }); - it('adjusts column-relative anchored images by table indent and cell offset', () => { + it('adjusts column-relative anchored images by cell offset after table indent is applied', () => { const para: ParagraphBlock = { kind: 'paragraph', id: 'para-anchor', @@ -659,7 +659,7 @@ describe('renderTableCell', () => { const imgEl = cellElement.querySelector('img.superdoc-table-image') as HTMLImageElement | null; expect(imgEl).toBeTruthy(); - expect(imgEl?.parentElement?.style.left).toBe('40px'); + expect(imgEl?.parentElement?.style.left).toBe('60px'); }); it('absolutely positions anchored drawing blocks inside table cells', () => { diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 08142826e0..7fea2c6ae1 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -1126,8 +1126,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen const objectHeight = anchoredMeasure.height; const baseLeft = anchor.offsetH ?? 0; - const indentOffset = typeof tableIndent === 'number' && Number.isFinite(tableIndent) ? tableIndent : 0; - const left = anchor.hRelativeFrom === 'column' ? baseLeft - x - indentOffset : baseLeft; + const left = anchor.hRelativeFrom === 'column' ? baseLeft - x : baseLeft; const top = anchor.offsetV ?? 0; const behindDoc = From 6e05a718ae3df65f66fc51f08bdd1accbfd371a9 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 12 Jun 2026 15:44:47 -0300 Subject: [PATCH 02/30] fix: render DrawingML connector line ends correctly --- .../src/renderer-shape-regressions.test.ts | 52 +++++++++++++++++++ .../painters/dom/src/renderer.ts | 12 ++--- .../extensions/shape-group/ShapeGroupView.js | 14 ++--- .../vector-shape/VectorShapeView.js | 14 ++--- 4 files changed, 72 insertions(+), 20 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts b/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts index 703ed1de13..117ae320fd 100644 --- a/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts @@ -101,6 +101,58 @@ describe('DomPainter shape regressions', () => { ); }); + it('renders DrawingML tailEnd on the visual end of a straight connector', () => { + const geometry: DrawingGeometry = { width: 450, height: 1, rotation: 0, flipH: false, flipV: false }; + const drawingBlock: DrawingFlowBlock = { + kind: 'drawing', + id: 'straight-connector-tail-end', + drawingKind: 'vectorShape', + geometry, + shapeKind: 'straightConnector1', + fillColor: null, + strokeColor: '#5b9bd5', + strokeWidth: 1, + lineEnds: { + tail: { type: 'triangle' }, + }, + }; + + const { blocks, measures, layout } = createDrawingFixtures(drawingBlock); + const painter = createDomPainter({ blocks, measures }); + painter.paint(layout, mount); + + const line = mount.querySelector('.superdoc-vector-shape svg line') as SVGLineElement | null; + expect(line?.getAttribute('marker-start')).toBeNull(); + expect(line?.getAttribute('marker-end')).toContain('straight-connector-tail-end'); + }); + + it('renders DrawingML headEnd on the visual start of a straight connector', () => { + const geometry: DrawingGeometry = { width: 450, height: 1, rotation: 0, flipH: false, flipV: false }; + const drawingBlock: DrawingFlowBlock = { + kind: 'drawing', + id: 'straight-connector-head-end', + drawingKind: 'vectorShape', + geometry, + shapeKind: 'straightConnector1', + fillColor: null, + strokeColor: '#5b9bd5', + strokeWidth: 1, + lineEnds: { + head: { type: 'triangle' }, + }, + }; + + const { blocks, measures, layout } = createDrawingFixtures(drawingBlock); + const painter = createDomPainter({ blocks, measures }); + painter.paint(layout, mount); + + const line = mount.querySelector('.superdoc-vector-shape svg line') as SVGLineElement | null; + const marker = mount.querySelector('.superdoc-vector-shape svg marker[id*="head"]') as SVGMarkerElement | null; + expect(line?.getAttribute('marker-start')).toContain('straight-connector-head-end'); + expect(line?.getAttribute('marker-end')).toBeNull(); + expect(marker?.querySelector('path')?.getAttribute('d')).toBe('M 10 0 L 0 5 L 10 10 Z'); + }); + it('generates roundRect preset geometry in the target coordinate space', () => { const width = 430; const height = 262; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 12d558067f..b641df33f2 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3897,12 +3897,12 @@ export class DomPainter { const defs = this.ensureSvgDefs(svgElement); const baseId = this.sanitizeSvgId(`sd-line-${block.id}`); - if (lineEnds.tail) { - const id = `${baseId}-tail`; + if (lineEnds.head) { + const id = `${baseId}-head`; this.appendLineEndMarker( defs, id, - lineEnds.tail, + lineEnds.head, strokeColor, strokeWidth, true, @@ -3911,12 +3911,12 @@ export class DomPainter { target.setAttribute('marker-start', `url(#${id})`); } - if (lineEnds.head) { - const id = `${baseId}-head`; + if (lineEnds.tail) { + const id = `${baseId}-tail`; this.appendLineEndMarker( defs, id, - lineEnds.head, + lineEnds.tail, strokeColor, strokeWidth, false, diff --git a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js index 6cf05348f0..0146d1bf5b 100644 --- a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js +++ b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js @@ -544,15 +544,15 @@ export class ShapeGroupView { applyLineEndsToTarget(target, lineEnds, strokeColor, strokeWidth, defs, markerBase) { if (!lineEnds || strokeColor === null || strokeWidth <= 0) return; - if (lineEnds.tail) { - const id = `${markerBase}-tail`; - this.createLineEndMarker(defs, id, lineEnds.tail, strokeColor, strokeWidth, true, null); + if (lineEnds.head) { + const id = `${markerBase}-head`; + this.createLineEndMarker(defs, id, lineEnds.head, strokeColor, strokeWidth, true, null); target.setAttribute('marker-start', `url(#${id})`); } - if (lineEnds.head) { - const id = `${markerBase}-head`; - this.createLineEndMarker(defs, id, lineEnds.head, strokeColor, strokeWidth, false, null); + if (lineEnds.tail) { + const id = `${markerBase}-tail`; + this.createLineEndMarker(defs, id, lineEnds.tail, strokeColor, strokeWidth, false, null); target.setAttribute('marker-end', `url(#${id})`); } } @@ -564,7 +564,7 @@ export class ShapeGroupView { * @param {Object} lineEnd - Line end configuration with type, width, length * @param {string} strokeColor - Color to use for the marker fill * @param {number} _strokeWidth - Stroke width (currently unused, reserved for future scaling) - * @param {boolean} isStart - Whether this is a start marker (tail) or end marker (head) + * @param {boolean} isStart - Whether this is a start marker (head) or end marker (tail) * @param {Object|null} effectExtent - Effect extent for sizing, or null */ createLineEndMarker(defs, id, lineEnd, strokeColor, _strokeWidth, isStart, effectExtent) { diff --git a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js index 8aa2ad7c6b..e67a72670b 100644 --- a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js +++ b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js @@ -456,15 +456,15 @@ export class VectorShapeView { svg.insertBefore(document.createElementNS('http://www.w3.org/2000/svg', 'defs'), svg.firstChild); const idBase = `line-end-${Math.random().toString(36).slice(2, 8)}-${Date.now()}`; - if (lineEnds.tail) { - const id = `${idBase}-tail`; - this.createLineEndMarker(defs, id, lineEnds.tail, strokeColor, strokeWidth, true, attrs.effectExtent); + if (lineEnds.head) { + const id = `${idBase}-head`; + this.createLineEndMarker(defs, id, lineEnds.head, strokeColor, strokeWidth, true, attrs.effectExtent); target.setAttribute('marker-start', `url(#${id})`); } - if (lineEnds.head) { - const id = `${idBase}-head`; - this.createLineEndMarker(defs, id, lineEnds.head, strokeColor, strokeWidth, false, attrs.effectExtent); + if (lineEnds.tail) { + const id = `${idBase}-tail`; + this.createLineEndMarker(defs, id, lineEnds.tail, strokeColor, strokeWidth, false, attrs.effectExtent); target.setAttribute('marker-end', `url(#${id})`); } } @@ -476,7 +476,7 @@ export class VectorShapeView { * @param {Object} lineEnd - Line end configuration with type, width, length * @param {string} strokeColor - Color to use for the marker fill * @param {number} _strokeWidth - Stroke width (currently unused, reserved for future scaling) - * @param {boolean} isStart - Whether this is a start marker (tail) or end marker (head) + * @param {boolean} isStart - Whether this is a start marker (head) or end marker (tail) * @param {Object|null} effectExtent - Effect extent for sizing, or null */ createLineEndMarker(defs, id, lineEnd, strokeColor, _strokeWidth, isStart, effectExtent) { From b8f2a2819d1f267783a649e55d7e7fa1a1e87f4d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 12 Jun 2026 16:00:49 -0300 Subject: [PATCH 03/30] fix: tune DrawingML connector arrowheads --- .../src/renderer-shape-regressions.test.ts | 31 +++++++++++++++-- .../painters/dom/src/renderer.ts | 33 ++++--------------- .../extensions/shape-group/ShapeGroupView.js | 17 ++++------ .../vector-shape/VectorShapeView.js | 19 ++++------- 4 files changed, 48 insertions(+), 52 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts b/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts index 117ae320fd..a7911eeccf 100644 --- a/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts @@ -126,6 +126,33 @@ describe('DomPainter shape regressions', () => { expect(line?.getAttribute('marker-end')).toContain('straight-connector-tail-end'); }); + it('keeps straight connector line-end markers stroke-relative when effect extent is present', () => { + const geometry: DrawingGeometry = { width: 450, height: 16, rotation: 0, flipH: false, flipV: false }; + const drawingBlock: DrawingFlowBlock = { + kind: 'drawing', + id: 'straight-connector-effect-extent-tail-end', + drawingKind: 'vectorShape', + geometry, + effectExtent: { left: 0, top: 7, right: 0, bottom: 8 }, + shapeKind: 'straightConnector1', + fillColor: null, + strokeColor: '#5b9bd5', + strokeWidth: 1, + lineEnds: { + tail: { type: 'triangle' }, + }, + }; + + const { blocks, measures, layout } = createDrawingFixtures(drawingBlock); + const painter = createDomPainter({ blocks, measures }); + painter.paint(layout, mount); + + const marker = mount.querySelector('.superdoc-vector-shape svg marker[id*="tail"]') as SVGMarkerElement | null; + expect(marker?.getAttribute('markerUnits')).toBe('strokeWidth'); + expect(marker?.getAttribute('markerWidth')).toBe('8'); + expect(marker?.getAttribute('markerHeight')).toBe('8'); + }); + it('renders DrawingML headEnd on the visual start of a straight connector', () => { const geometry: DrawingGeometry = { width: 450, height: 1, rotation: 0, flipH: false, flipV: false }; const drawingBlock: DrawingFlowBlock = { @@ -1116,8 +1143,8 @@ describe('DomPainter shape regressions', () => { const marker = childWrapper?.querySelector('marker') as SVGMarkerElement | null; expect(marker).toBeTruthy(); expect(marker?.getAttribute('markerUnits')).toBe('strokeWidth'); - expect(marker?.getAttribute('markerWidth')).toBe('4'); - expect(marker?.getAttribute('markerHeight')).toBe('4'); + expect(marker?.getAttribute('markerWidth')).toBe('8'); + expect(marker?.getAttribute('markerHeight')).toBe('8'); expect(childWrapper?.querySelector('feDropShadow')).toBeTruthy(); }); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index b641df33f2..4f9b247a62 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3899,29 +3899,13 @@ export class DomPainter { if (lineEnds.head) { const id = `${baseId}-head`; - this.appendLineEndMarker( - defs, - id, - lineEnds.head, - strokeColor, - strokeWidth, - true, - block.effectExtent ?? undefined, - ); + this.appendLineEndMarker(defs, id, lineEnds.head, strokeColor, strokeWidth, true); target.setAttribute('marker-start', `url(#${id})`); } if (lineEnds.tail) { const id = `${baseId}-tail`; - this.appendLineEndMarker( - defs, - id, - lineEnds.tail, - strokeColor, - strokeWidth, - false, - block.effectExtent ?? undefined, - ); + this.appendLineEndMarker(defs, id, lineEnds.tail, strokeColor, strokeWidth, false); target.setAttribute('marker-end', `url(#${id})`); } } @@ -4076,7 +4060,6 @@ export class DomPainter { strokeColor: string, _strokeWidth: number, isStart: boolean, - effectExtent?: EffectExtent, ): void { if (defs.querySelector(`#${id}`)) return; @@ -4090,13 +4073,9 @@ export class DomPainter { if (value === 'lg') return 1.25; return 1; }; - const effectMax = effectExtent - ? Math.max(effectExtent.left ?? 0, effectExtent.right ?? 0, effectExtent.top ?? 0, effectExtent.bottom ?? 0) - : 0; - const useEffectExtent = Number.isFinite(effectMax) && effectMax > 0; - const markerWidth = useEffectExtent ? effectMax * 2 : 4 * sizeScale(lineEnd.length); - const markerHeight = useEffectExtent ? effectMax * 2 : 4 * sizeScale(lineEnd.width); - marker.setAttribute('markerUnits', useEffectExtent ? 'userSpaceOnUse' : 'strokeWidth'); + const markerWidth = 8 * sizeScale(lineEnd.length); + const markerHeight = 8 * sizeScale(lineEnd.width); + marker.setAttribute('markerUnits', 'strokeWidth'); marker.setAttribute('markerWidth', markerWidth.toString()); marker.setAttribute('markerHeight', markerHeight.toString()); marker.setAttribute('refX', isStart ? '0' : '10'); @@ -4268,7 +4247,7 @@ export class DomPainter { } const attrs = child.attrs as VectorShapeStyle; // Producers must include equivalent group-level effectExtent for edge children so the fragment can grow. - // Line-end markers use effectExtent for marker sizing; do not overload it with stroke paint room. + // Line-end markers use stroke-relative sizing; do not overload this with stroke paint room. const shadowExtent = attrs.effects?.outerShadow ? getSharedOuterShadowPaintExtent(attrs.effects.outerShadow) : { left: 0, top: 0, right: 0, bottom: 0 }; diff --git a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js index 0146d1bf5b..821acf0cb8 100644 --- a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js +++ b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js @@ -546,13 +546,13 @@ export class ShapeGroupView { if (lineEnds.head) { const id = `${markerBase}-head`; - this.createLineEndMarker(defs, id, lineEnds.head, strokeColor, strokeWidth, true, null); + this.createLineEndMarker(defs, id, lineEnds.head, strokeColor, strokeWidth, true); target.setAttribute('marker-start', `url(#${id})`); } if (lineEnds.tail) { const id = `${markerBase}-tail`; - this.createLineEndMarker(defs, id, lineEnds.tail, strokeColor, strokeWidth, false, null); + this.createLineEndMarker(defs, id, lineEnds.tail, strokeColor, strokeWidth, false); target.setAttribute('marker-end', `url(#${id})`); } } @@ -565,9 +565,8 @@ export class ShapeGroupView { * @param {string} strokeColor - Color to use for the marker fill * @param {number} _strokeWidth - Stroke width (currently unused, reserved for future scaling) * @param {boolean} isStart - Whether this is a start marker (head) or end marker (tail) - * @param {Object|null} effectExtent - Effect extent for sizing, or null */ - createLineEndMarker(defs, id, lineEnd, strokeColor, _strokeWidth, isStart, effectExtent) { + createLineEndMarker(defs, id, lineEnd, strokeColor, _strokeWidth, isStart) { if (defs.querySelector(`#${id}`)) return; const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); @@ -580,13 +579,9 @@ export class ShapeGroupView { if (value === 'lg') return 1.25; return 1; }; - const effectMax = effectExtent - ? Math.max(effectExtent.left || 0, effectExtent.right || 0, effectExtent.top || 0, effectExtent.bottom || 0) - : 0; - const useEffectExtent = Number.isFinite(effectMax) && effectMax > 0; - const markerWidth = useEffectExtent ? effectMax * 2 : 4 * sizeScale(lineEnd.length); - const markerHeight = useEffectExtent ? effectMax * 2 : 4 * sizeScale(lineEnd.width); - marker.setAttribute('markerUnits', useEffectExtent ? 'userSpaceOnUse' : 'strokeWidth'); + const markerWidth = 8 * sizeScale(lineEnd.length); + const markerHeight = 8 * sizeScale(lineEnd.width); + marker.setAttribute('markerUnits', 'strokeWidth'); marker.setAttribute('markerWidth', markerWidth.toString()); marker.setAttribute('markerHeight', markerHeight.toString()); marker.setAttribute('refX', isStart ? '0' : '10'); diff --git a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js index e67a72670b..7555290e8e 100644 --- a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js +++ b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js @@ -438,7 +438,7 @@ export class VectorShapeView { /** * Applies line end markers (arrowheads) to an SVG element. * @param {SVGElement} svg - The SVG element to apply markers to - * @param {Object} attrs - Shape attributes containing lineEnds, strokeColor, strokeWidth, effectExtent + * @param {Object} attrs - Shape attributes containing lineEnds, strokeColor, strokeWidth */ applyLineEnds(svg, attrs) { const lineEnds = attrs.lineEnds; @@ -458,13 +458,13 @@ export class VectorShapeView { if (lineEnds.head) { const id = `${idBase}-head`; - this.createLineEndMarker(defs, id, lineEnds.head, strokeColor, strokeWidth, true, attrs.effectExtent); + this.createLineEndMarker(defs, id, lineEnds.head, strokeColor, strokeWidth, true); target.setAttribute('marker-start', `url(#${id})`); } if (lineEnds.tail) { const id = `${idBase}-tail`; - this.createLineEndMarker(defs, id, lineEnds.tail, strokeColor, strokeWidth, false, attrs.effectExtent); + this.createLineEndMarker(defs, id, lineEnds.tail, strokeColor, strokeWidth, false); target.setAttribute('marker-end', `url(#${id})`); } } @@ -477,9 +477,8 @@ export class VectorShapeView { * @param {string} strokeColor - Color to use for the marker fill * @param {number} _strokeWidth - Stroke width (currently unused, reserved for future scaling) * @param {boolean} isStart - Whether this is a start marker (head) or end marker (tail) - * @param {Object|null} effectExtent - Effect extent for sizing, or null */ - createLineEndMarker(defs, id, lineEnd, strokeColor, _strokeWidth, isStart, effectExtent) { + createLineEndMarker(defs, id, lineEnd, strokeColor, _strokeWidth, isStart) { if (defs.querySelector(`#${id}`)) return; const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); @@ -492,13 +491,9 @@ export class VectorShapeView { if (value === 'lg') return 1.25; return 1; }; - const effectMax = effectExtent - ? Math.max(effectExtent.left || 0, effectExtent.right || 0, effectExtent.top || 0, effectExtent.bottom || 0) - : 0; - const useEffectExtent = Number.isFinite(effectMax) && effectMax > 0; - const markerWidth = useEffectExtent ? effectMax * 2 : 4 * sizeScale(lineEnd.length); - const markerHeight = useEffectExtent ? effectMax * 2 : 4 * sizeScale(lineEnd.width); - marker.setAttribute('markerUnits', useEffectExtent ? 'userSpaceOnUse' : 'strokeWidth'); + const markerWidth = 8 * sizeScale(lineEnd.length); + const markerHeight = 8 * sizeScale(lineEnd.width); + marker.setAttribute('markerUnits', 'strokeWidth'); marker.setAttribute('markerWidth', markerWidth.toString()); marker.setAttribute('markerHeight', markerHeight.toString()); marker.setAttribute('refX', isStart ? '0' : '10'); From 659db4c689a5eca82b061fe0f634dc4db126629d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 12 Jun 2026 16:14:44 -0300 Subject: [PATCH 04/30] fix: keep connector strokes uniform --- .../src/renderer-shape-regressions.test.ts | 23 ++++++++++++++ .../painters/dom/src/renderer.ts | 25 ++++++++++++++++ .../extensions/shape-group/ShapeGroupView.js | 28 +++++++++++++++++ .../vector-shape/VectorShapeView.js | 26 ++++++++++++++++ .../vector-shape/VectorShapeView.test.js | 30 +++++++++++++++++++ 5 files changed, 132 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts b/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts index a7911eeccf..a49abbc5c3 100644 --- a/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts @@ -180,6 +180,29 @@ describe('DomPainter shape regressions', () => { expect(marker?.querySelector('path')?.getAttribute('d')).toBe('M 10 0 L 0 5 L 10 10 Z'); }); + it('keeps bent connector stroke thickness uniform under non-uniform SVG scaling', () => { + const geometry: DrawingGeometry = { width: 427, height: 28, rotation: 0, flipH: false, flipV: false }; + const drawingBlock: DrawingFlowBlock = { + kind: 'drawing', + id: 'bent-connector-uniform-stroke', + drawingKind: 'vectorShape', + geometry, + shapeKind: 'bentConnector3', + fillColor: null, + strokeColor: '#5b9bd5', + strokeWidth: 1, + }; + + const { blocks, measures, layout } = createDrawingFixtures(drawingBlock); + const painter = createDomPainter({ blocks, measures }); + painter.paint(layout, mount); + + const path = mount.querySelector('.superdoc-vector-shape svg path') as SVGPathElement | null; + expect(path?.getAttribute('d')).toBe('M 0 0 L 50 0 L 50 100 L 100 100'); + expect(path?.getAttribute('stroke-width')).toBe('1'); + expect(path?.getAttribute('vector-effect')).toBe('non-scaling-stroke'); + }); + it('generates roundRect preset geometry in the target coordinate space', () => { const width = 430; const height = 262; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 4f9b247a62..f72db500ef 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -866,6 +866,16 @@ const DEFAULT_VIRTUALIZED_PAGE_GAP = 72; const PAGE_BACKGROUND_OVERLAY_Z_ORDER_OFFSET = 1_000_000; const SVG_NS = 'http://www.w3.org/2000/svg'; const WORDART_LINE_FILL_RATIO = 0.9; +const CONNECTOR_PRESET_SHAPES = new Set([ + 'bentConnector2', + 'bentConnector3', + 'bentConnector4', + 'bentConnector5', + 'curvedConnector2', + 'curvedConnector3', + 'curvedConnector4', + 'curvedConnector5', +]); // Comment highlight color tokens moved to CommentHighlightDecorator (super-editor). /** @@ -3219,6 +3229,9 @@ export class DomPainter { if (resolvedSvgMarkup) { const svgElement = this.parseSafeSvg(resolvedSvgMarkup); if (svgElement) { + if (!customGeomSvg && this.isConnectorPresetShape(block.shapeKind)) { + this.applyNonScalingStrokeToConnector(svgElement); + } svgElement.setAttribute('width', '100%'); svgElement.setAttribute('height', '100%'); svgElement.style.display = 'block'; @@ -3302,6 +3315,18 @@ export class DomPainter { } } + private isConnectorPresetShape(shapeKind?: string | null): boolean { + return typeof shapeKind === 'string' && CONNECTOR_PRESET_SHAPES.has(shapeKind); + } + + private applyNonScalingStrokeToConnector(svgElement: SVGElement): void { + svgElement.querySelectorAll('path, line, polyline').forEach((target) => { + const stroke = target.getAttribute('stroke'); + if (!stroke || stroke === 'none') return; + target.setAttribute('vector-effect', 'non-scaling-stroke'); + }); + } + private hasShapeTextContent(textContent?: ShapeTextContent): textContent is ShapeTextContent { return Array.isArray(textContent?.parts) && textContent.parts.length > 0; } diff --git a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js index 821acf0cb8..54a7cd5e94 100644 --- a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js +++ b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js @@ -3,6 +3,27 @@ import { getPresetShapeSvg } from '@superdoc/preset-geometry'; import { createGradient, createTextElement } from '../shared/svg-utils.js'; import { OOXML_Z_INDEX_BASE } from '@extensions/shared/constants.js'; +const CONNECTOR_PRESET_SHAPES = new Set([ + 'bentConnector2', + 'bentConnector3', + 'bentConnector4', + 'bentConnector5', + 'curvedConnector2', + 'curvedConnector3', + 'curvedConnector4', + 'curvedConnector5', +]); + +function isConnectorPresetShape(kind) { + return typeof kind === 'string' && CONNECTOR_PRESET_SHAPES.has(kind); +} + +function applyNonScalingStrokeToConnectorTarget(target) { + const stroke = target.getAttribute('stroke'); + if (!stroke || stroke === 'none') return; + target.setAttribute('vector-effect', 'non-scaling-stroke'); +} + export class ShapeGroupView { node; @@ -484,6 +505,13 @@ export class ShapeGroupView { lineEndsApplied = true; } + if ( + isConnectorPresetShape(shapeKind) && + (clonedChild.tagName === 'path' || clonedChild.tagName === 'line' || clonedChild.tagName === 'polyline') + ) { + applyNonScalingStrokeToConnectorTarget(clonedChild); + } + g.appendChild(clonedChild); }); } diff --git a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js index 7555290e8e..0c29c075d9 100644 --- a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js +++ b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js @@ -10,6 +10,29 @@ import { generateTransforms, } from '../shared/svg-utils.js'; +const CONNECTOR_PRESET_SHAPES = new Set([ + 'bentConnector2', + 'bentConnector3', + 'bentConnector4', + 'bentConnector5', + 'curvedConnector2', + 'curvedConnector3', + 'curvedConnector4', + 'curvedConnector5', +]); + +function isConnectorPresetShape(kind) { + return typeof kind === 'string' && CONNECTOR_PRESET_SHAPES.has(kind); +} + +function applyNonScalingStrokeToConnector(svgElement) { + svgElement.querySelectorAll('path, line, polyline').forEach((target) => { + const stroke = target.getAttribute('stroke'); + if (!stroke || stroke === 'none') return; + target.setAttribute('vector-effect', 'non-scaling-stroke'); + }); +} + export class VectorShapeView { node; @@ -404,6 +427,9 @@ export class VectorShapeView { tempDiv.innerHTML = svgTemplate; const tempSvg = tempDiv.querySelector('svg'); if (tempSvg) { + if (isConnectorPresetShape(kind)) { + applyNonScalingStrokeToConnector(tempSvg); + } // Preserve the preset viewBox and scale via width/height tempSvg.setAttribute('width', width.toString()); tempSvg.setAttribute('height', height.toString()); diff --git a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.test.js b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.test.js index 1f9c123069..d777721448 100644 --- a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.test.js +++ b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.test.js @@ -409,6 +409,36 @@ describe('VectorShapeView', () => { expect(svg.getAttribute('width')).toBe('200'); expect(svg.getAttribute('height')).toBe('50'); }); + + it('keeps connector preset strokes non-scaling under non-uniform scaling', () => { + const connectorNode = { + attrs: { + kind: 'bentConnector3', + width: 427, + height: 28, + fillColor: null, + strokeColor: '#5b9bd5', + strokeWidth: 1, + }, + }; + + const mockSvgTemplate = + ''; + presetGeometry.getPresetShapeSvg.mockReturnValue(mockSvgTemplate); + + const view = new VectorShapeView({ + node: connectorNode, + editor: mockEditor, + getPos: mockGetPos, + decorations: [], + innerDecorations: [], + extension: {}, + htmlAttributes: {}, + }); + + const path = view.dom.querySelector('svg path'); + expect(path.getAttribute('vector-effect')).toBe('non-scaling-stroke'); + }); }); describe('edge cases and error handling', () => { From c20b5a46632db1fdcad0f26a6f4351171de88531 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 12 Jun 2026 16:27:31 -0300 Subject: [PATCH 05/30] fix: render connector arrows in target space --- .../src/renderer-shape-regressions.test.ts | 39 +++++++- .../painters/dom/src/renderer.ts | 69 +++++++++++++- .../painters/dom/src/table/renderTableCell.ts | 6 +- .../dom/src/table/renderTableFragment.ts | 5 - .../dom/src/table/renderTableRow.test.ts | 1 - .../painters/dom/src/table/renderTableRow.ts | 4 - .../extensions/shape-group/ShapeGroupView.js | 94 ++++++++++++++++++- .../vector-shape/VectorShapeView.js | 62 +++++++++++- .../vector-shape/VectorShapeView.test.js | 18 ++-- 9 files changed, 274 insertions(+), 24 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts b/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts index a49abbc5c3..ed3656f393 100644 --- a/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts @@ -180,7 +180,7 @@ describe('DomPainter shape regressions', () => { expect(marker?.querySelector('path')?.getAttribute('d')).toBe('M 10 0 L 0 5 L 10 10 Z'); }); - it('keeps bent connector stroke thickness uniform under non-uniform SVG scaling', () => { + it('renders bent connector strokes in target coordinates', () => { const geometry: DrawingGeometry = { width: 427, height: 28, rotation: 0, flipH: false, flipV: false }; const drawingBlock: DrawingFlowBlock = { kind: 'drawing', @@ -198,11 +198,46 @@ describe('DomPainter shape regressions', () => { painter.paint(layout, mount); const path = mount.querySelector('.superdoc-vector-shape svg path') as SVGPathElement | null; - expect(path?.getAttribute('d')).toBe('M 0 0 L 50 0 L 50 100 L 100 100'); + const svg = mount.querySelector('.superdoc-vector-shape svg') as SVGSVGElement | null; + expect(svg?.getAttribute('viewBox')).toBe('-0.5 -0.5 428 29'); + expect(path?.getAttribute('d')).toBe('M 0 0 L 213.5 0 L 213.5 28 L 427 28'); expect(path?.getAttribute('stroke-width')).toBe('1'); expect(path?.getAttribute('vector-effect')).toBe('non-scaling-stroke'); }); + it('renders bent connector arrowheads without a stretched 100x100 connector viewBox', () => { + const geometry: DrawingGeometry = { width: 418, height: 169, rotation: 0, flipH: false, flipV: false }; + const drawingBlock: DrawingFlowBlock = { + kind: 'drawing', + id: 'bent-connector-arrow-uniform-stroke', + drawingKind: 'vectorShape', + geometry, + shapeKind: 'bentConnector3', + fillColor: null, + strokeColor: '#5b9bd5', + strokeWidth: 1, + lineEnds: { + tail: { type: 'triangle' }, + }, + }; + + const { blocks, measures, layout } = createDrawingFixtures(drawingBlock); + const painter = createDomPainter({ blocks, measures }); + painter.paint(layout, mount); + + const svg = mount.querySelector('.superdoc-vector-shape svg') as SVGSVGElement | null; + const path = Array.from(svg?.querySelectorAll('path') ?? []).find((candidate) => !candidate.closest('marker')) as + | SVGPathElement + | undefined; + const marker = svg?.querySelector('marker[id*="tail"]') as SVGMarkerElement | null; + expect(svg?.getAttribute('viewBox')).toBe('-0.5 -0.5 419 170'); + expect(path?.getAttribute('d')).toBe('M 0 0 L 209 0 L 209 169 L 418 169'); + expect(path?.getAttribute('marker-end')).toContain('bent-connector-arrow-uniform-stroke'); + expect(path?.getAttribute('vector-effect')).toBe('non-scaling-stroke'); + expect(marker?.getAttribute('markerUnits')).toBe('strokeWidth'); + expect(marker?.querySelector('path')?.getAttribute('d')).toBe('M 0 0 L 10 5 L 0 10 Z'); + }); + it('generates roundRect preset geometry in the target coordinate space', () => { const width = 430; const height = 262; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index f72db500ef..683290b61e 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -876,6 +876,7 @@ const CONNECTOR_PRESET_SHAPES = new Set([ 'curvedConnector4', 'curvedConnector5', ]); +const CONNECTOR_SVG_ELEMENTS = 'path, line, polyline'; // Comment highlight color tokens moved to CommentHighlightDecorator (super-editor). /** @@ -3320,7 +3321,7 @@ export class DomPainter { } private applyNonScalingStrokeToConnector(svgElement: SVGElement): void { - svgElement.querySelectorAll('path, line, polyline').forEach((target) => { + svgElement.querySelectorAll(CONNECTOR_SVG_ELEMENTS).forEach((target) => { const stroke = target.getAttribute('stroke'); if (!stroke || stroke === 'none') return; target.setAttribute('vector-effect', 'non-scaling-stroke'); @@ -3771,6 +3772,15 @@ export class DomPainter { `; } + if (this.isConnectorPresetShape(block.shapeKind)) { + const connectorSvg = this.tryCreateConnectorPresetSvg( + block, + widthOverride ?? block.geometry.width, + heightOverride ?? block.geometry.height, + ); + if (connectorSvg) return connectorSvg; + } + return getPresetShapeSvg({ preset: block.shapeKind ?? '', styleOverrides: () => ({ @@ -3787,6 +3797,63 @@ export class DomPainter { } } + private tryCreateConnectorPresetSvg( + block: ShapeTextDrawingWithEffects, + width: number, + height: number, + ): string | null { + const pathD = this.getConnectorPresetPath(block.shapeKind, width, height); + if (!pathD) return null; + + const stroke = + block.strokeColor === null ? 'none' : typeof block.strokeColor === 'string' ? block.strokeColor : '#000000'; + const strokeWidth = block.strokeWidth ?? 1; + const formattedWidth = this.formatSvgNumber(width); + const formattedHeight = this.formatSvgNumber(height); + const strokePadding = stroke !== 'none' && strokeWidth > 0 ? strokeWidth / 2 : 0; + const viewBoxX = this.formatSvgNumber(-strokePadding); + const viewBoxY = this.formatSvgNumber(-strokePadding); + const viewBoxWidth = this.formatSvgNumber(width + strokePadding * 2); + const viewBoxHeight = this.formatSvgNumber(height + strokePadding * 2); + + return ` + +`; + } + + private getConnectorPresetPath(shapeKind: string | null | undefined, width: number, height: number): string | null { + const w = Math.max(0, width); + const h = Math.max(0, height); + const xMid = w / 2; + const yMid = h / 2; + const xQuarter = w * 0.25; + const xThreeQuarter = w * 0.75; + const yQuarter = h * 0.25; + const yThreeQuarter = h * 0.75; + const fmt = (value: number): string => this.formatSvgNumber(value); + + switch (shapeKind) { + case 'bentConnector2': + return `M 0 0 L ${fmt(w)} 0 L ${fmt(w)} ${fmt(h)}`; + case 'bentConnector3': + return `M 0 0 L ${fmt(xMid)} 0 L ${fmt(xMid)} ${fmt(h)} L ${fmt(w)} ${fmt(h)}`; + case 'bentConnector4': + return `M 0 0 L ${fmt(xMid)} 0 L ${fmt(xMid)} ${fmt(yMid)} L ${fmt(w)} ${fmt(yMid)} L ${fmt(w)} ${fmt(h)}`; + case 'bentConnector5': + return `M 0 0 L ${fmt(xMid)} 0 L ${fmt(xMid)} ${fmt(yMid)} L ${fmt(xMid)} ${fmt(yMid)} L ${fmt(xMid)} ${fmt(h)} L ${fmt(w)} ${fmt(h)}`; + case 'curvedConnector2': + return `M 0 0 C ${fmt(xMid)} 0 ${fmt(w)} ${fmt(yMid)} ${fmt(w)} ${fmt(h)}`; + case 'curvedConnector3': + return `M 0 0 C ${fmt(xQuarter)} 0 ${fmt(xMid)} ${fmt(yQuarter)} ${fmt(xMid)} ${fmt(yMid)} C ${fmt(xMid)} ${fmt(yThreeQuarter)} ${fmt(xThreeQuarter)} ${fmt(h)} ${fmt(w)} ${fmt(h)}`; + case 'curvedConnector4': + return `M 0 0 C ${fmt(xQuarter)} 0 ${fmt(xMid)} ${fmt(h * 0.125)} ${fmt(xMid)} ${fmt(yQuarter)} C ${fmt(xMid)} ${fmt(h * 0.375)} ${fmt(w * 0.625)} ${fmt(yMid)} ${fmt(xThreeQuarter)} ${fmt(yMid)} C ${fmt(w * 0.875)} ${fmt(yMid)} ${fmt(w)} ${fmt(yThreeQuarter)} ${fmt(w)} ${fmt(h)}`; + case 'curvedConnector5': + return `M 0 0 C ${fmt(xQuarter)} 0 ${fmt(xMid)} ${fmt(h * 0.125)} ${fmt(xMid)} ${fmt(yQuarter)} C ${fmt(xMid)} ${fmt(h * 0.375)} ${fmt(xMid)} ${fmt(yMid)} ${fmt(xMid)} ${fmt(yMid)} C ${fmt(xMid)} ${fmt(yMid)} ${fmt(xMid)} ${fmt(h * 0.625)} ${fmt(xMid)} ${fmt(yThreeQuarter)} C ${fmt(xMid)} ${fmt(h * 0.875)} ${fmt(xThreeQuarter)} ${fmt(h)} ${fmt(w)} ${fmt(h)}`; + default: + return null; + } + } + /** * Creates an SVG string from custom geometry path data (a:custGeom). * Each path in the custom geometry has its own coordinate space (w × h) which is diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 7fea2c6ae1..5043211765 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -626,8 +626,6 @@ type TableCellRenderDependencies = { ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; /** Receives notification when this cell or descendants render SDT container chrome */ onSdtContainerChrome?: () => void; - /** Table indent in pixels (applied to table fragment positioning) */ - tableIndent?: number; /** Whether the table is visually right-to-left (w:bidiVisual, ECMA-376 §17.4.1) */ isRtl?: boolean; /** Computed cell width from rescaled columnWidths (overrides cellMeasure.width when present) */ @@ -728,7 +726,6 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, - tableIndent, isRtl, cellWidth, fromLine, @@ -1126,6 +1123,9 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen const objectHeight = anchoredMeasure.height; const baseLeft = anchor.offsetH ?? 0; + // ECMA-376 ST_RelFromH "column" anchors are offset from the containing column's + // extents. The imported offsetH already represents wp:posOffset in that base; + // subtract only the cell's local x, not the table's tblInd, or table images shift left. const left = anchor.hRelativeFrom === 'column' ? baseLeft - x : baseLeft; const top = anchor.offsetV ?? 0; diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index 834ea7a727..a7c547f0c3 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -213,9 +213,6 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement throw new Error('Document is required for table rendering'); } const tableBorders = block.attrs?.borders; - const tableIndentValue = (block.attrs?.tableIndent as { width?: unknown } | null | undefined)?.width; - const tableIndent = typeof tableIndentValue === 'number' && Number.isFinite(tableIndentValue) ? tableIndentValue : 0; - // RTL table: w:bidiVisual (ECMA-376 §17.4.1) — cells displayed right-to-left, // table-level properties (borders, margins, indent) are mirrored. const isRtl = getTableVisualDirection(block.attrs) === 'rtl'; @@ -530,7 +527,6 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement tableBorders, columnWidths: effectiveColumnWidths, allRowHeights, - tableIndent, isRtl, context, renderLine, @@ -708,7 +704,6 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement tableBorders, columnWidths: effectiveColumnWidths, allRowHeights, - tableIndent, isRtl, context, renderLine, diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts index 9a33035ea8..88e2002be8 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts @@ -48,7 +48,6 @@ describe('renderTableRow', () => { }, columnWidths: [100], allRowHeights: [20, 20, 20, 20, 20, 20, 20, 20, 20, 20], - tableIndent: 0, context: { sectionIndex: 0, pageIndex: 0, columnIndex: 0 }, renderLine: () => doc.createElement('div'), applySdtDataset: () => {}, diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts index 7ac9672a9c..90bb96c794 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts @@ -268,8 +268,6 @@ type TableRowRenderDependencies = { columnWidths: number[]; /** All row heights for calculating rowspan cell heights */ allRowHeights: number[]; - /** Table indent in pixels (applied to table fragment positioning) */ - tableIndent?: number; /** Whether the table is visually right-to-left (w:bidiVisual, ECMA-376 §17.4.1) */ isRtl?: boolean; /** Rendering context */ @@ -540,7 +538,6 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { tableBorders, columnWidths, allRowHeights, - tableIndent, isRtl, context, renderLine, @@ -902,7 +899,6 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { onSdtContainerChrome, fromLine, toLine, - tableIndent, isRtl, cellWidth: computedCellWidth > 0 ? computedCellWidth : undefined, chrome, diff --git a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js index 54a7cd5e94..3de58b3dbb 100644 --- a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js +++ b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js @@ -13,7 +13,6 @@ const CONNECTOR_PRESET_SHAPES = new Set([ 'curvedConnector4', 'curvedConnector5', ]); - function isConnectorPresetShape(kind) { return typeof kind === 'string' && CONNECTOR_PRESET_SHAPES.has(kind); } @@ -24,6 +23,47 @@ function applyNonScalingStrokeToConnectorTarget(target) { target.setAttribute('vector-effect', 'non-scaling-stroke'); } +function formatSvgNumber(value) { + return Number.isFinite(value) ? Number(value.toFixed(4)).toString() : '0'; +} + +function getConnectorPresetPath(kind, width, height) { + const w = Math.max(0, width); + const h = Math.max(0, height); + const xMid = w / 2; + const yMid = h / 2; + const xQuarter = w * 0.25; + const xThreeQuarter = w * 0.75; + const yQuarter = h * 0.25; + const yThreeQuarter = h * 0.75; + const fmt = formatSvgNumber; + + switch (kind) { + case 'bentConnector2': + return `M 0 0 L ${fmt(w)} 0 L ${fmt(w)} ${fmt(h)}`; + case 'bentConnector3': + return `M 0 0 L ${fmt(xMid)} 0 L ${fmt(xMid)} ${fmt(h)} L ${fmt(w)} ${fmt(h)}`; + case 'bentConnector4': + return `M 0 0 L ${fmt(xMid)} 0 L ${fmt(xMid)} ${fmt(yMid)} L ${fmt(w)} ${fmt(yMid)} L ${fmt(w)} ${fmt(h)}`; + case 'bentConnector5': + return `M 0 0 L ${fmt(xMid)} 0 L ${fmt(xMid)} ${fmt(yMid)} L ${fmt(xMid)} ${fmt(yMid)} L ${fmt(xMid)} ${fmt(h)} L ${fmt(w)} ${fmt(h)}`; + case 'curvedConnector2': + return `M 0 0 C ${fmt(xMid)} 0 ${fmt(w)} ${fmt(yMid)} ${fmt(w)} ${fmt(h)}`; + case 'curvedConnector3': + return `M 0 0 C ${fmt(xQuarter)} 0 ${fmt(xMid)} ${fmt(yQuarter)} ${fmt(xMid)} ${fmt(yMid)} C ${fmt(xMid)} ${fmt(yThreeQuarter)} ${fmt(xThreeQuarter)} ${fmt(h)} ${fmt(w)} ${fmt(h)}`; + case 'curvedConnector4': + return `M 0 0 C ${fmt(xQuarter)} 0 ${fmt(xMid)} ${fmt(h * 0.125)} ${fmt(xMid)} ${fmt(yQuarter)} C ${fmt(xMid)} ${fmt(h * 0.375)} ${fmt(w * 0.625)} ${fmt(yMid)} ${fmt(xThreeQuarter)} ${fmt(yMid)} C ${fmt(w * 0.875)} ${fmt(yMid)} ${fmt(w)} ${fmt(yThreeQuarter)} ${fmt(w)} ${fmt(h)}`; + case 'curvedConnector5': + return `M 0 0 C ${fmt(xQuarter)} 0 ${fmt(xMid)} ${fmt(h * 0.125)} ${fmt(xMid)} ${fmt(yQuarter)} C ${fmt(xMid)} ${fmt(h * 0.375)} ${fmt(xMid)} ${fmt(yMid)} ${fmt(xMid)} ${fmt(yMid)} C ${fmt(xMid)} ${fmt(yMid)} ${fmt(xMid)} ${fmt(h * 0.625)} ${fmt(xMid)} ${fmt(yThreeQuarter)} C ${fmt(xMid)} ${fmt(h * 0.875)} ${fmt(xThreeQuarter)} ${fmt(h)} ${fmt(w)} ${fmt(h)}`; + default: + return null; + } +} + +function getConnectorStrokePadding(strokeColor, strokeWidth) { + return strokeColor !== null && strokeWidth > 0 ? strokeWidth / 2 : 0; +} + export class ShapeGroupView { node; @@ -412,6 +452,58 @@ export class ShapeGroupView { return g; } + if (isConnectorPresetShape(shapeKind)) { + const strokePadding = getConnectorStrokePadding(strokeColor, strokeWidth); + const pathWidth = Math.max(0, width - strokePadding * 2); + const pathHeight = Math.max(0, height - strokePadding * 2); + const pathD = getConnectorPresetPath(shapeKind, pathWidth, pathHeight); + if (pathD) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', pathD); + path.setAttribute('fill', 'none'); + path.setAttribute('stroke', strokeColor === null ? 'none' : strokeColor || '#000000'); + path.setAttribute('stroke-width', (strokeColor === null ? 0 : strokeWidth).toString()); + if (strokePadding > 0) { + path.setAttribute( + 'transform', + `translate(${formatSvgNumber(strokePadding)}, ${formatSvgNumber(strokePadding)})`, + ); + } + applyNonScalingStrokeToConnectorTarget(path); + + if (lineEnds && strokeColor !== null) { + const markerBase = `line-end-${shapeIndex}-${Date.now()}-${Math.floor(Math.random() * 1e9)}`; + this.applyLineEndsToTarget(path, lineEnds, strokeColor, strokeWidth, defs, markerBase); + } + g.appendChild(path); + } + + if (attrs.textContent && attrs.textContent.parts) { + const pageNumber = this.editor?.options?.currentPageNumber; + const pageNumberText = this.editor?.options?.currentPageNumberText; + const pageNumberDisplayNumber = this.editor?.options?.currentPageDisplayNumber; + const pageNumberChapterText = this.editor?.options?.currentPageChapterNumberText; + const pageNumberChapterSeparator = this.editor?.options?.currentPageChapterSeparator; + const totalPages = this.editor?.options?.totalPageCount; + const sectionPageCount = this.editor?.options?.sectionPageCount; + const textGroup = this.createTextElement(attrs.textContent, attrs.textAlign, width, height, { + textVerticalAlign: attrs.textVerticalAlign, + textInsets: attrs.textInsets, + pageNumber, + pageNumberText, + pageNumberDisplayNumber, + pageNumberChapterText, + pageNumberChapterSeparator, + totalPages, + sectionPageCount, + }); + if (textGroup) { + g.appendChild(textGroup); + } + } + return g; + } + // Fall through to preset shape rendering (default to 'rect' if no kind) try { const svgContent = getPresetShapeSvg({ diff --git a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js index 0c29c075d9..fc7957d07d 100644 --- a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js +++ b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js @@ -20,19 +20,75 @@ const CONNECTOR_PRESET_SHAPES = new Set([ 'curvedConnector4', 'curvedConnector5', ]); +const CONNECTOR_SVG_ELEMENTS = 'path, line, polyline'; function isConnectorPresetShape(kind) { return typeof kind === 'string' && CONNECTOR_PRESET_SHAPES.has(kind); } function applyNonScalingStrokeToConnector(svgElement) { - svgElement.querySelectorAll('path, line, polyline').forEach((target) => { + svgElement.querySelectorAll(CONNECTOR_SVG_ELEMENTS).forEach((target) => { const stroke = target.getAttribute('stroke'); if (!stroke || stroke === 'none') return; target.setAttribute('vector-effect', 'non-scaling-stroke'); }); } +function formatSvgNumber(value) { + return Number.isFinite(value) ? Number(value.toFixed(4)).toString() : '0'; +} + +function getConnectorPresetPath(kind, width, height) { + const w = Math.max(0, width); + const h = Math.max(0, height); + const xMid = w / 2; + const yMid = h / 2; + const xQuarter = w * 0.25; + const xThreeQuarter = w * 0.75; + const yQuarter = h * 0.25; + const yThreeQuarter = h * 0.75; + const fmt = formatSvgNumber; + + switch (kind) { + case 'bentConnector2': + return `M 0 0 L ${fmt(w)} 0 L ${fmt(w)} ${fmt(h)}`; + case 'bentConnector3': + return `M 0 0 L ${fmt(xMid)} 0 L ${fmt(xMid)} ${fmt(h)} L ${fmt(w)} ${fmt(h)}`; + case 'bentConnector4': + return `M 0 0 L ${fmt(xMid)} 0 L ${fmt(xMid)} ${fmt(yMid)} L ${fmt(w)} ${fmt(yMid)} L ${fmt(w)} ${fmt(h)}`; + case 'bentConnector5': + return `M 0 0 L ${fmt(xMid)} 0 L ${fmt(xMid)} ${fmt(yMid)} L ${fmt(xMid)} ${fmt(yMid)} L ${fmt(xMid)} ${fmt(h)} L ${fmt(w)} ${fmt(h)}`; + case 'curvedConnector2': + return `M 0 0 C ${fmt(xMid)} 0 ${fmt(w)} ${fmt(yMid)} ${fmt(w)} ${fmt(h)}`; + case 'curvedConnector3': + return `M 0 0 C ${fmt(xQuarter)} 0 ${fmt(xMid)} ${fmt(yQuarter)} ${fmt(xMid)} ${fmt(yMid)} C ${fmt(xMid)} ${fmt(yThreeQuarter)} ${fmt(xThreeQuarter)} ${fmt(h)} ${fmt(w)} ${fmt(h)}`; + case 'curvedConnector4': + return `M 0 0 C ${fmt(xQuarter)} 0 ${fmt(xMid)} ${fmt(h * 0.125)} ${fmt(xMid)} ${fmt(yQuarter)} C ${fmt(xMid)} ${fmt(h * 0.375)} ${fmt(w * 0.625)} ${fmt(yMid)} ${fmt(xThreeQuarter)} ${fmt(yMid)} C ${fmt(w * 0.875)} ${fmt(yMid)} ${fmt(w)} ${fmt(yThreeQuarter)} ${fmt(w)} ${fmt(h)}`; + case 'curvedConnector5': + return `M 0 0 C ${fmt(xQuarter)} 0 ${fmt(xMid)} ${fmt(h * 0.125)} ${fmt(xMid)} ${fmt(yQuarter)} C ${fmt(xMid)} ${fmt(h * 0.375)} ${fmt(xMid)} ${fmt(yMid)} ${fmt(xMid)} ${fmt(yMid)} C ${fmt(xMid)} ${fmt(yMid)} ${fmt(xMid)} ${fmt(h * 0.625)} ${fmt(xMid)} ${fmt(yThreeQuarter)} C ${fmt(xMid)} ${fmt(h * 0.875)} ${fmt(xThreeQuarter)} ${fmt(h)} ${fmt(w)} ${fmt(h)}`; + default: + return null; + } +} + +function createConnectorPresetSvg({ kind, strokeColor, strokeWidth, width, height }) { + const pathD = getConnectorPresetPath(kind, width, height); + if (!pathD) return null; + + const stroke = strokeColor === null ? 'none' : strokeColor || '#000000'; + const resolvedStrokeWidth = strokeWidth ?? 1; + const formattedWidth = formatSvgNumber(width); + const formattedHeight = formatSvgNumber(height); + const strokePadding = stroke !== 'none' && resolvedStrokeWidth > 0 ? resolvedStrokeWidth / 2 : 0; + const viewBoxX = formatSvgNumber(-strokePadding); + const viewBoxY = formatSvgNumber(-strokePadding); + const viewBoxWidth = formatSvgNumber(width + strokePadding * 2); + const viewBoxHeight = formatSvgNumber(height + strokePadding * 2); + return ` + +`; +} + export class VectorShapeView { node; @@ -571,6 +627,10 @@ export class VectorShapeView { generateSVG({ kind, fillColor, strokeColor, strokeWidth, width, height }) { try { + if (isConnectorPresetShape(kind)) { + return createConnectorPresetSvg({ kind, strokeColor, strokeWidth, width, height }); + } + // For complex fill types (gradients, alpha), use a placeholder or extract the color let fill = fillColor || 'none'; if (fillColor && typeof fillColor === 'object') { diff --git a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.test.js b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.test.js index d777721448..2c6f3f2ef4 100644 --- a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.test.js +++ b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.test.js @@ -410,7 +410,7 @@ describe('VectorShapeView', () => { expect(svg.getAttribute('height')).toBe('50'); }); - it('keeps connector preset strokes non-scaling under non-uniform scaling', () => { + it('renders connector presets in target coordinates with non-scaling strokes and line ends', () => { const connectorNode = { attrs: { kind: 'bentConnector3', @@ -419,13 +419,12 @@ describe('VectorShapeView', () => { fillColor: null, strokeColor: '#5b9bd5', strokeWidth: 1, + lineEnds: { + tail: { type: 'triangle' }, + }, }, }; - const mockSvgTemplate = - ''; - presetGeometry.getPresetShapeSvg.mockReturnValue(mockSvgTemplate); - const view = new VectorShapeView({ node: connectorNode, editor: mockEditor, @@ -436,8 +435,15 @@ describe('VectorShapeView', () => { htmlAttributes: {}, }); - const path = view.dom.querySelector('svg path'); + const svg = view.dom.querySelector('svg'); + const path = [...view.dom.querySelectorAll('svg path')].find((candidate) => !candidate.closest('marker')); + const marker = view.dom.querySelector('svg marker'); + expect(presetGeometry.getPresetShapeSvg).not.toHaveBeenCalled(); + expect(svg.getAttribute('viewBox')).toBe('-0.5 -0.5 428 29'); + expect(path.getAttribute('d')).toBe('M 0 0 L 213.5 0 L 213.5 28 L 427 28'); expect(path.getAttribute('vector-effect')).toBe('non-scaling-stroke'); + expect(path.getAttribute('marker-end')).toContain('tail'); + expect(marker.getAttribute('markerUnits')).toBe('strokeWidth'); }); }); From 6f1cea42c709ce3e15ded17c8f1ad77e88575bba Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 12 Jun 2026 16:35:26 -0300 Subject: [PATCH 06/30] fix(painter): stretch connector SVG viewBox --- .../painters/dom/src/renderer-shape-regressions.test.ts | 1 + packages/layout-engine/painters/dom/src/renderer.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts b/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts index ed3656f393..2c7f610c80 100644 --- a/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts @@ -200,6 +200,7 @@ describe('DomPainter shape regressions', () => { const path = mount.querySelector('.superdoc-vector-shape svg path') as SVGPathElement | null; const svg = mount.querySelector('.superdoc-vector-shape svg') as SVGSVGElement | null; expect(svg?.getAttribute('viewBox')).toBe('-0.5 -0.5 428 29'); + expect(svg?.getAttribute('preserveAspectRatio')).toBe('none'); expect(path?.getAttribute('d')).toBe('M 0 0 L 213.5 0 L 213.5 28 L 427 28'); expect(path?.getAttribute('stroke-width')).toBe('1'); expect(path?.getAttribute('vector-effect')).toBe('non-scaling-stroke'); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 683290b61e..d9a5166ee5 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3816,7 +3816,7 @@ export class DomPainter { const viewBoxWidth = this.formatSvgNumber(width + strokePadding * 2); const viewBoxHeight = this.formatSvgNumber(height + strokePadding * 2); - return ` + return ` `; } From cb94937b4eb32d898b0cb146011e20fc52a82e12 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 12 Jun 2026 17:13:01 -0300 Subject: [PATCH 07/30] fix: render vector shape picture fills --- packages/layout-engine/contracts/src/index.ts | 14 +++- .../src/versionSignature.test.ts | 14 ++++ .../layout-resolved/src/versionSignature.ts | 8 +- .../src/renderer-shape-regressions.test.ts | 39 +++++++++- .../painters/dom/src/renderer.ts | 74 ++++++++++++++++++- .../v1/core/layout-adapter/utilities.test.ts | 68 +++++++++++++++++ .../v1/core/layout-adapter/utilities.ts | 34 ++++++++- .../wp/helpers/encode-image-node-helpers.js | 4 +- .../wp/helpers/vector-shape-helpers.js | 41 +++++++++- .../wp/helpers/vector-shape-helpers.test.js | 41 +++++++++- 10 files changed, 327 insertions(+), 10 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index ae60be84c2..5312c557d3 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1075,14 +1075,26 @@ export type SolidFillWithAlpha = { alpha: number; }; +/** Picture fill for DrawingML vector shapes. */ +export type PictureFill = { + type: 'picture'; + /** Image source path or hydrated data URI. */ + src: string; + /** Source relationship id from the owning document part. */ + rId?: string; + /** Source image extension, used when resolving media fallbacks. */ + extension?: string; +}; + /** * Fill color for shapes. Can be: * - string: Simple hex color (e.g., "#FF0000") for backward compatibility * - GradientFill: Linear or radial gradient * - SolidFillWithAlpha: Solid color with transparency + * - PictureFill: Image fill clipped by the shape geometry * - null: No fill */ -export type FillColor = string | GradientFill | SolidFillWithAlpha | null; +export type FillColor = string | GradientFill | SolidFillWithAlpha | PictureFill | null; /** * Stroke color for shapes. Can be: diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts index 0a39a3d8dc..ac7af8dcb1 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts @@ -243,6 +243,20 @@ describe('deriveBlockVersion - vector shape effects', () => { expect(deriveBlockVersion(withExtent)).not.toBe(deriveBlockVersion(base)); }); + + it('changes when an object fill changes', () => { + const base = makeVectorShape(); + const withPictureA: VectorShapeDrawing = { + ...base, + fillColor: { type: 'picture', src: 'data:image/png;base64,AAA', rId: 'rId1', extension: 'png' }, + }; + const withPictureB: VectorShapeDrawing = { + ...base, + fillColor: { type: 'picture', src: 'data:image/png;base64,BBB', rId: 'rId2', extension: 'png' }, + }; + + expect(deriveBlockVersion(withPictureB)).not.toBe(deriveBlockVersion(withPictureA)); + }); }); describe('deriveBlockVersion - table image content', () => { diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index 672a866dc2..30bda18594 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -57,6 +57,12 @@ const getSdtMetadataVersion = (metadata: SdtMetadata | null | undefined): string return [metadata.type, getSdtMetadataLockMode(metadata), getSdtMetadataId(metadata)].join(':'); }; +const valueVersion = (value: unknown): string => { + if (value == null) return ''; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +}; + const getTrackedChangeLayers = (run: TextRun): TrackedChangeMeta[] => { if (Array.isArray(run.trackedChanges) && run.trackedChanges.length > 0) { return run.trackedChanges; @@ -478,7 +484,7 @@ export const deriveBlockVersion = (block: FlowBlock): string => { return [ block.drawingKind === 'textboxShape' ? 'drawing:textbox' : 'drawing:vector', vector.shapeKind ?? '', - vector.fillColor ?? '', + valueVersion(vector.fillColor), vector.strokeColor ?? '', vector.strokeWidth ?? '', vector.geometry.width, diff --git a/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts b/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts index 2c7f610c80..68c07a0a20 100644 --- a/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { createTestPainter as createDomPainter } from './_test-utils.js'; import { getPresetShapeSvg } from '@superdoc/preset-geometry'; -import type { DrawingGeometry, FlowBlock, Layout, Measure, SolidFillWithAlpha } from '@superdoc/contracts'; +import type { DrawingGeometry, FlowBlock, Layout, Measure, PictureFill, SolidFillWithAlpha } from '@superdoc/contracts'; type DrawingFlowBlock = Extract; @@ -356,6 +356,43 @@ describe('DomPainter shape regressions', () => { expect(path?.getAttribute('fill-opacity')).toBe(String(alphaFill.alpha)); }); + it('renders picture fills clipped by preset shape geometry', () => { + const geometry: DrawingGeometry = { width: 120, height: 120, rotation: 0, flipH: false, flipV: false }; + const pictureFill: PictureFill = { + type: 'picture', + src: 'data:image/jpeg;base64,profileBase64', + rId: 'rId6', + extension: 'jpeg', + }; + + const drawingBlock: DrawingFlowBlock = { + kind: 'drawing', + id: 'ellipse-picture-fill', + drawingKind: 'vectorShape', + geometry, + shapeKind: 'ellipse', + fillColor: pictureFill, + strokeColor: '#5b9bd5', + strokeWidth: 4, + }; + + const { blocks, measures, layout } = createDrawingFixtures(drawingBlock); + const painter = createDomPainter({ blocks, measures }); + painter.paint(layout, mount); + + const svg = mount.querySelector('.superdoc-vector-shape svg') as SVGSVGElement | null; + const fillTarget = svg?.querySelector('ellipse, path') as SVGElement | null; + const pattern = svg?.querySelector('pattern[id^="sd-picture-fill-"]') as SVGPatternElement | null; + const image = pattern?.querySelector('image') as SVGImageElement | null; + + expect(pattern).toBeTruthy(); + expect(pattern?.getAttribute('patternContentUnits')).toBe('objectBoundingBox'); + expect(image?.getAttribute('href')).toBe(pictureFill.src); + expect(image?.getAttribute('preserveAspectRatio')).toBe('none'); + expect(fillTarget?.getAttribute('fill')).toBe(`url(#${pattern?.id})`); + expect(fillTarget?.getAttribute('stroke')).toBe('#5b9bd5'); + }); + it('keeps explicit custom-geometry strokes visible for EMU-sized coordinate spaces', () => { const geometry: DrawingGeometry = { width: 84, height: 45, rotation: 0, flipH: false, flipV: false }; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index d9a5166ee5..dd6d308267 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -19,6 +19,7 @@ import type { PageNumberFormat, ParaFragment, ParagraphBlock, + PictureFill, PositionedDrawingGeometry, Run, ShapeGroupChild, @@ -3207,7 +3208,7 @@ export class DomPainter { container.style.width = '100%'; container.style.height = '100%'; container.style.position = 'relative'; - container.style.overflow = 'hidden'; + container.style.overflow = 'visible'; const { offsetX, offsetY, innerWidth, innerHeight } = this.getEffectExtentMetrics(block, geometry); const contentContainer = this.doc!.createElement('div'); @@ -3232,6 +3233,8 @@ export class DomPainter { if (svgElement) { if (!customGeomSvg && this.isConnectorPresetShape(block.shapeKind)) { this.applyNonScalingStrokeToConnector(svgElement); + } else { + this.expandSvgViewBoxForCenteredStroke(svgElement); } svgElement.setAttribute('width', '100%'); svgElement.setAttribute('height', '100%'); @@ -3244,6 +3247,8 @@ export class DomPainter { applyGradientToSVG(svgElement, block.fillColor as GradientFill); } else if ('type' in block.fillColor && block.fillColor.type === 'solidWithAlpha') { applyAlphaToSVG(svgElement, block.fillColor as SolidFillWithAlpha); + } else if ('type' in block.fillColor && block.fillColor.type === 'picture') { + this.applyPictureFillToSVG(svgElement, block.fillColor as PictureFill, block.id); } } @@ -3300,6 +3305,10 @@ export class DomPainter { // For CSS gradients in fallback, we'd need to convert // For now, use a placeholder color container.style.background = 'rgba(15, 23, 42, 0.1)'; + } else if (block.fillColor.type === 'picture') { + container.style.backgroundImage = `url("${(block.fillColor as PictureFill).src}")`; + container.style.backgroundSize = '100% 100%'; + container.style.backgroundRepeat = 'no-repeat'; } } else { container.style.background = 'rgba(15, 23, 42, 0.1)'; @@ -3756,6 +3765,8 @@ export class DomPainter { fillColor = 'none'; } else if (typeof block.fillColor === 'string') { fillColor = block.fillColor; + } else if (typeof block.fillColor === 'object') { + fillColor = '#000000'; } const strokeColor = block.strokeColor === null ? 'none' : typeof block.strokeColor === 'string' ? block.strokeColor : undefined; @@ -3797,6 +3808,67 @@ export class DomPainter { } } + private applyPictureFillToSVG(svgElement: SVGElement, fill: PictureFill, blockId: string): void { + if (!fill.src) return; + const targets = this.findShapeEffectTargets(svgElement); + if (targets.length === 0) return; + + const patternId = this.sanitizeSvgId(`sd-picture-fill-${blockId}`); + const defs = this.ensureSvgDefs(svgElement); + + const pattern = this.doc!.createElementNS(SVG_NS, 'pattern'); + pattern.setAttribute('id', patternId); + pattern.setAttribute('patternUnits', 'objectBoundingBox'); + pattern.setAttribute('patternContentUnits', 'objectBoundingBox'); + pattern.setAttribute('width', '1'); + pattern.setAttribute('height', '1'); + + const image = this.doc!.createElementNS(SVG_NS, 'image'); + image.setAttribute('href', fill.src); + image.setAttribute('x', '0'); + image.setAttribute('y', '0'); + image.setAttribute('width', '1'); + image.setAttribute('height', '1'); + image.setAttribute('preserveAspectRatio', 'none'); + pattern.appendChild(image); + defs.appendChild(pattern); + + targets.forEach((target) => { + target.setAttribute('fill', `url(#${patternId})`); + }); + } + + private expandSvgViewBoxForCenteredStroke(svgElement: SVGElement): void { + const viewBox = svgElement.getAttribute('viewBox'); + if (!viewBox) return; + + const parts = viewBox + .trim() + .split(/[\s,]+/) + .map((part) => Number(part)); + if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) return; + + const maxStrokeWidth = this.findShapeEffectTargets(svgElement).reduce((max, target) => { + const stroke = target.getAttribute('stroke'); + if (!stroke || stroke === 'none') return max; + const strokeWidth = Number(target.getAttribute('stroke-width') ?? 1); + return Number.isFinite(strokeWidth) && strokeWidth > max ? strokeWidth : max; + }, 0); + if (maxStrokeWidth <= 0) return; + + const padding = maxStrokeWidth / 2; + const [x, y, width, height] = parts; + svgElement.setAttribute( + 'viewBox', + [ + this.formatSvgNumber(x - padding), + this.formatSvgNumber(y - padding), + this.formatSvgNumber(width + padding * 2), + this.formatSvgNumber(height + padding * 2), + ].join(' '), + ); + } + private tryCreateConnectorPresetSvg( block: ShapeTextDrawingWithEffects, width: number, diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.test.ts index a9f1dbfff0..0d7111bd1b 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.test.ts @@ -1073,6 +1073,74 @@ describe('Media Utilities', () => { expect(result[0]).toBe(blocks[0]); // Same reference, no changes }); + it('hydrates picture fills on vector shape drawing blocks', () => { + const blocks: FlowBlock[] = [ + { + kind: 'drawing', + id: 'drawing-picture-fill', + drawingKind: 'vectorShape', + geometry: { width: 100, height: 100, rotation: 0, flipH: false, flipV: false }, + shapeKind: 'ellipse', + fillColor: { + type: 'picture', + src: 'word/media/profile.jpeg', + rId: 'rId6', + extension: 'jpeg', + }, + } as unknown as FlowBlock, + ]; + const mediaFiles = { 'word/media/profile.jpeg': 'profileBase64' }; + + const result = hydrateImageBlocks(blocks, mediaFiles); + const drawingBlock = result[0] as unknown as { + fillColor: { type: 'picture'; src: string; rId: string; extension: string }; + }; + + expect(drawingBlock).not.toBe(blocks[0]); + expect(drawingBlock.fillColor).toEqual({ + type: 'picture', + src: 'data:image/jpeg;base64,profileBase64', + rId: 'rId6', + extension: 'jpeg', + }); + }); + + it('hydrates picture fills on shapeGroup vector children', () => { + const blocks: FlowBlock[] = [ + { + kind: 'drawing', + id: 'drawing-group-picture-fill', + drawingKind: 'shapeGroup', + geometry: { width: 200, height: 200, rotation: 0, flipH: false, flipV: false }, + shapes: [ + { + shapeType: 'vectorShape', + attrs: { + x: 0, + y: 0, + width: 100, + height: 100, + kind: 'ellipse', + fillColor: { + type: 'picture', + src: 'word/media/profile.png', + extension: 'png', + }, + }, + }, + ], + } as unknown as FlowBlock, + ]; + const mediaFiles = { 'word/media/profile.png': 'profilePngBase64' }; + + const result = hydrateImageBlocks(blocks, mediaFiles); + const drawingBlock = result[0] as unknown as { + shapes: Array<{ attrs: { fillColor: { type: 'picture'; src: string } } }>; + }; + + expect(drawingBlock.shapes[0].attrs.fillColor.src).toBe('data:image/png;base64,profilePngBase64'); + }); + it('handles shapeGroup with empty shapes array', () => { const blocks: FlowBlock[] = [ { diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.ts index 524789cd0c..1496001342 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.ts @@ -11,6 +11,7 @@ import type { DrawingContentSnapshot, ImageBlock, ImageHyperlink, + PictureFill, ShapeGroupChild, ShapeGroupDrawing, ShapeGroupImageChild, @@ -1179,6 +1180,14 @@ export function hydrateImageBlocks(blocks: FlowBlock[], mediaFiles?: Record { + if (!isPictureFill(fillColor) || fillColor.src.startsWith('data:')) { + return undefined; + } + const resolvedSrc = resolveImageSrc(fillColor.src, fillColor.rId, undefined, fillColor.extension); + return resolvedSrc ? { ...fillColor, src: resolvedSrc } : undefined; + }; + return blocks.map((block) => { const hydrateBlock = (blk: FlowBlock): FlowBlock => { // Handle ImageBlocks (top-level images) @@ -1279,6 +1288,8 @@ export function hydrateImageBlocks(blocks: FlowBlock[], mediaFiles?: Record { + if (shape.shapeType === 'vectorShape') { + const hydratedFillColor = hydratePictureFill(shape.attrs.fillColor); + if (!hydratedFillColor) { + return shape; + } + shapesChanged = true; + return { + ...shape, + attrs: { ...shape.attrs, fillColor: hydratedFillColor }, + }; + } + // Only process image children if (shape.shapeType !== 'image') { return shape; @@ -1490,9 +1516,13 @@ export function isSolidFillWithAlpha(value: unknown): value is import('@superdoc ); } +function isPictureFill(value: unknown): value is PictureFill { + return isPlainObject(value) && value.type === 'picture' && typeof value.src === 'string'; +} + /** * Normalizes a fill color value to a valid FillColor type. - * Preserves gradient objects, solid with alpha objects, string colors, and null. + * Preserves gradient objects, solid with alpha objects, picture fills, string colors, and null. * * @param value - Raw fill color value from ProseMirror node * @returns Normalized FillColor or undefined if invalid @@ -1502,6 +1532,7 @@ export function isSolidFillWithAlpha(value: unknown): value is import('@superdoc * normalizeFillColor('#FF0000'); // '#FF0000' (string pass-through) * normalizeFillColor({ type: 'gradient', ... }); // GradientFill object * normalizeFillColor({ type: 'solidWithAlpha', color: '#FF0000', alpha: 0.5 }); // SolidFillWithAlpha + * normalizeFillColor({ type: 'picture', src: 'word/media/image1.jpeg' }); // PictureFill * normalizeFillColor(null); // null (no fill) * normalizeFillColor(123); // undefined (invalid) * ``` @@ -1511,6 +1542,7 @@ export function normalizeFillColor(value: unknown): import('@superdoc/contracts' if (typeof value === 'string') return value; if (isGradientFill(value)) return value; if (isSolidFillWithAlpha(value)) return value; + if (isPictureFill(value)) return value; return undefined; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js index ef5a36a0df..3d444e1e54 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js @@ -1010,7 +1010,7 @@ const parseShapeGroupVectorChild = (wsp, transform, params) => { const rect = transformShapeGroupChildRect(transform, rawX, rawY, rawWidth, rawHeight); const orientation = composeShapeGroupChildOrientation(rect, shapeXfrm); const style = findChildByLocalName(wsp.elements, 'style'); - const fillColor = extractFillColor(spPr, style); + const fillColor = extractFillColor(spPr, style, params); const strokeColor = extractStrokeColor(spPr, style); const strokeWidth = extractStrokeWidth(spPr); const lineEnds = extractLineEnds(spPr); @@ -1738,7 +1738,7 @@ export function getVectorShape({ // Extract colors const style = wsp.elements?.find((el) => el.name === 'wps:style'); - const fillColor = extractFillColor(spPr, style); + const fillColor = extractFillColor(spPr, style, params); const strokeColor = extractStrokeColor(spPr, style); const strokeWidth = extractStrokeWidth(spPr); const lineEnds = extractLineEnds(spPr); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js index 562244f39e..cf1af95667 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js @@ -213,6 +213,42 @@ function extractColorFromElement(element) { return null; } +const normalizeTargetPath = (targetPath = '') => { + if (!targetPath) return targetPath; + const trimmed = targetPath.replace(/^\/+/, ''); + if (trimmed.startsWith('word/')) return trimmed; + if (trimmed.startsWith('media/')) return `word/${trimmed}`; + return `word/${trimmed}`; +}; + +const inferExtensionFromPath = (path = '') => { + const match = String(path).match(/\.([a-zA-Z0-9]+)(?:[#?].*)?$/); + return match?.[1]?.toLowerCase(); +}; + +function resolveBlipFillPicture(blipFill, params) { + const blip = findChildByLocalName(blipFill?.elements, 'blip'); + const rEmbed = blip?.attributes?.['r:embed']; + if (!rEmbed || !params?.docx) return null; + + const currentFile = params.filename || 'document.xml'; + let rels = params.docx[`word/_rels/${currentFile}.rels`]; + if (!rels) rels = params.docx[`word/_rels/document.xml.rels`]; + + const relationships = rels?.elements?.find((el) => el.name === 'Relationships'); + const rel = relationships?.elements?.find((el) => el.attributes?.['Id'] === rEmbed); + const target = rel?.attributes?.['Target']; + if (!target) return null; + + const src = normalizeTargetPath(target); + return { + type: 'picture', + src, + rId: rEmbed, + extension: inferExtensionFromPath(src), + }; +} + /** * Converts a theme color name to its corresponding hex color value. * Uses the default Office theme color palette. @@ -424,9 +460,10 @@ export function extractStrokeColor(spPr, style) { * Checks direct fill definition in spPr first, then falls back to style reference. * @param {Object} spPr - The shape properties element * @param {Object} style - The shape style element (wps:style) + * @param {Object} params - Optional converter context for relationship lookup * @returns {string|null} Hex color value */ -export function extractFillColor(spPr, style) { +export function extractFillColor(spPr, style, params) { const noFill = findChildByLocalName(spPr?.elements, 'noFill'); if (noFill) { return null; @@ -450,7 +487,7 @@ export function extractFillColor(spPr, style) { const blipFill = findChildByLocalName(spPr?.elements, 'blipFill'); if (blipFill) { - return '#cccccc'; // placeholder color for now + return resolveBlipFillPicture(blipFill, params) ?? '#cccccc'; } // No fill specified in spPr, check style reference diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js index e03eccba15..26aba5e7ca 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js @@ -413,10 +413,49 @@ describe('extractFillColor', () => { gradientType: 'linear', }); - // Image fills still return placeholder color expect(extractFillColor({ elements: [{ name: 'a:blipFill' }] }, null)).toBe('#cccccc'); }); + it('extracts picture fills from blipFill relationships', () => { + const spPr = { + elements: [ + { + name: 'a:blipFill', + elements: [{ name: 'a:blip', attributes: { 'r:embed': 'rId6' } }], + }, + ], + }; + const params = { + filename: 'document.xml', + docx: { + 'word/_rels/document.xml.rels': { + elements: [ + { + name: 'Relationships', + elements: [ + { + name: 'Relationship', + attributes: { + Id: 'rId6', + Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image', + Target: 'media/image1.jpeg', + }, + }, + ], + }, + ], + }, + }, + }; + + expect(extractFillColor(spPr, null, params)).toEqual({ + type: 'picture', + src: 'word/media/image1.jpeg', + rId: 'rId6', + extension: 'jpeg', + }); + }); + it('falls back to style when spPr has no fill', () => { const spPr = { elements: [] }; const style = { From eab651fdbf8a835721c605ffb50b73cccc170722 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 12 Jun 2026 17:33:55 -0300 Subject: [PATCH 08/30] fix: position page-relative header media --- .../layout-engine/src/index.test.ts | 30 ++++++---- .../layout-engine/layout-engine/src/index.ts | 15 ++--- .../normalize-header-footer-fragments.test.ts | 35 +++++++++++- .../src/normalize-header-footer-fragments.ts | 55 +++++++++++++++---- 4 files changed, 98 insertions(+), 37 deletions(-) diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 0a307a6b71..c3082b4124 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -5209,43 +5209,49 @@ describe('layoutHeaderFooter', () => { expect(imgFragWithout!.y).not.toBe(imgFragFooter!.y); }); - it('does NOT post-normalize page-relative anchors in header layout', () => { + it('post-normalizes page-relative anchors in header layout', () => { const imageBlock: FlowBlock = { kind: 'image', id: 'img-page', src: 'data:image/png;base64,xxx', anchor: { isAnchored: true, + hRelativeFrom: 'page', + alignH: 'center', vRelativeFrom: 'page', - alignV: 'top', - offsetV: 10, + alignV: 'center', + offsetH: 0, + offsetV: 0, }, }; const imageMeasure: Measure = { kind: 'image', - width: 50, - height: 30, + width: 762.24, + height: 1010.88, }; const constraints = { - width: 200, - height: 800, + width: 624, + height: 864, + pageWidth: 816, pageHeight: 1056, - margins: { left: 72, right: 72, top: 72, bottom: 72, header: 36 }, + margins: { left: 96, right: 96, top: 96, bottom: 96, header: 48 }, }; - // With kind='header': no normalization — Y stays as inner-layout computed it const withHeader = layoutHeaderFooter([imageBlock], [imageMeasure], constraints, 'header'); const imgFrag = withHeader.pages[0]?.fragments.find((f) => f.kind === 'image'); - // Without kind: same behavior (no normalization) + // Without kind: no header/footer normalization, so the inner synthetic canvas + // still resolves the anchor against the body-content-sized page. const withoutKind = layoutHeaderFooter([imageBlock], [imageMeasure], constraints); const imgFragNoKind = withoutKind.pages[0]?.fragments.find((f) => f.kind === 'image'); - // Both should have the same Y — inner-layout raw position expect(imgFrag).toBeDefined(); expect(imgFragNoKind).toBeDefined(); - expect(imgFrag!.y).toBe(imgFragNoKind!.y); + expect(imgFrag!.x).toBeCloseTo((816 - 762.24) / 2); + expect(imgFrag!.y).toBeCloseTo((1056 - 1010.88) / 2); + expect(imgFragNoKind!.x).not.toBe(imgFrag!.x); + expect(imgFragNoKind!.y).not.toBe(imgFrag!.y); }); it('keeps paragraph-relative tall non-page-covering header anchors in measurement height', () => { diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 2b8c426183..27d8f48280 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -3643,17 +3643,12 @@ export function layoutHeaderFooter( remeasureParagraph, }); - // Post-normalize page-relative anchored fragment Y positions for footers. + // Post-normalize page-relative anchored fragment positions for headers/footers. // - // The inner layoutDocument() uses the body content height as its page height, - // but page-relative anchors need the REAL physical page height to resolve - // bottom/center alignment correctly. This post-correction rewrites their Y - // to footer-band-local coordinates using the real page geometry. - // - // Headers don't need this: the inner layout's page-relative Y is already - // correct relative to the header container, and the painter handles the - // container-to-page offset via effectiveOffset subtraction. - if (kind === 'footer' && constraints.pageHeight != null) { + // The inner layoutDocument() uses the body content box as its page canvas, but + // page-relative anchors need the real physical page dimensions. This + // post-correction rewrites their coordinates using the real page geometry. + if (kind && constraints.pageHeight != null) { normalizeFragmentsForRegion(layout.pages, blocks, measures, kind, constraints); } diff --git a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts index 6a669bf847..bbe24c4f6f 100644 --- a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts +++ b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts @@ -10,8 +10,8 @@ function makeParaFragment(blockId: string, y: number): Fragment { return { kind: 'para', blockId, x: 0, y, fromLine: 0, toLine: 1 } as Fragment; } -function makeAnchoredImageFragment(blockId: string, y: number, height: number): Fragment { - return { kind: 'image', blockId, x: 0, y, height, isAnchored: true } as unknown as Fragment; +function makeAnchoredImageFragment(blockId: string, y: number, height: number, width = 0): Fragment { + return { kind: 'image', blockId, x: 0, y, width, height, isAnchored: true } as unknown as Fragment; } function makeDummyMeasure(): Measure { @@ -23,6 +23,7 @@ const MARGIN_BOTTOM = 72; const FOOTER_DISTANCE = 36; const fullConstraints = { + pageWidth: 816, pageHeight: PAGE_HEIGHT, margins: { left: 72, right: 72, top: 72, bottom: MARGIN_BOTTOM, header: 36, footer: FOOTER_DISTANCE }, }; @@ -33,7 +34,35 @@ const FOOTER_BAND_ORIGIN = PAGE_HEIGHT - FOOTER_DISTANCE; // 1020 // Tests // --------------------------------------------------------------------------- -describe('normalizeFragmentsForRegion (footer page-relative only)', () => { +describe('normalizeFragmentsForRegion (header/footer page-relative anchors)', () => { + describe('page-relative anchors in header', () => { + it('normalizes centered page-relative anchors against the physical page', () => { + const imgWidth = 762.24; + const imgHeight = 1010.88; + const block: FlowBlock = { + kind: 'image', + id: 'header-background', + src: 'test.png', + anchor: { + isAnchored: true, + hRelativeFrom: 'page', + alignH: 'center', + vRelativeFrom: 'page', + alignV: 'center', + offsetH: 0, + offsetV: 0, + }, + }; + const fragment = makeAnchoredImageFragment('header-background', 0, imgHeight, imgWidth); + const pages = [{ number: 1, fragments: [fragment] }]; + + normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'header', fullConstraints); + + expect(fragment.x).toBeCloseTo((816 - imgWidth) / 2); + expect(fragment.y).toBeCloseTo((PAGE_HEIGHT - imgHeight) / 2); + }); + }); + describe('page-relative anchors in footer', () => { it('normalizes a top-aligned anchor', () => { const block: FlowBlock = { diff --git a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts index 8ebd93882c..0245be1f57 100644 --- a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts +++ b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts @@ -12,6 +12,7 @@ import type { * Defined locally to avoid circular imports with index.ts. */ export type RegionConstraints = { + pageWidth?: number; pageHeight?: number; margins?: { left: number; @@ -23,6 +24,19 @@ export type RegionConstraints = { }; }; +function computePhysicalAnchorX(block: ImageBlock | DrawingBlock, fragmentWidth: number, pageWidth: number): number { + const alignH = block.anchor?.alignH ?? 'left'; + const offsetH = block.anchor?.offsetH ?? 0; + + if (alignH === 'right') { + return pageWidth - fragmentWidth - offsetH; + } + if (alignH === 'center') { + return (pageWidth - fragmentWidth) / 2 + offsetH; + } + return offsetH; +} + /** * Compute the physical-page Y coordinate for a page-relative anchored drawing, * using the real page geometry from constraints. @@ -58,6 +72,10 @@ function computeFooterBandOrigin(constraints: RegionConstraints): number { return Math.max(0, pageHeight - (constraints.margins?.bottom ?? 0)); } +function computeBandOrigin(kind: 'header' | 'footer', constraints: RegionConstraints): number { + return kind === 'footer' ? computeFooterBandOrigin(constraints) : 0; +} + function isAnchoredFragment(fragment: Fragment): boolean { return ( (fragment.kind === 'image' || fragment.kind === 'drawing') && @@ -66,21 +84,26 @@ function isAnchoredFragment(fragment: Fragment): boolean { } function isPageRelativeBlock(block: FlowBlock): block is ImageBlock | DrawingBlock { - return (block.kind === 'image' || block.kind === 'drawing') && block.anchor?.vRelativeFrom === 'page'; + return ( + (block.kind === 'image' || block.kind === 'drawing') && + (block.anchor?.hRelativeFrom === 'page' || block.anchor?.vRelativeFrom === 'page') + ); } /** - * Post-normalize page-relative anchored fragment Y positions in footer layout. + * Post-normalize page-relative anchored fragment positions in header/footer layout. * * Problem: The inner `layoutDocument()` uses body content height as its page - * height. For page-relative anchors with bottom/center alignment, this produces - * incorrect Y positions because the real physical page is much taller. + * height and content width as its page size. For page-relative anchors with + * center/right or bottom/center alignment, this produces incorrect positions + * because the real physical page is larger. * - * Solution: After layout, rewrite each page-relative anchored fragment's Y - * using the real physical page height, then convert to footer-band-local - * coordinates (where y=0 = top of the bottom margin area). + * Solution: After layout, rewrite each page-relative anchored fragment using + * the real physical page dimensions, then convert Y to the region's local + * coordinate system. Headers use page-local Y; footers use footer-band-local Y + * where y=0 is the top of the bottom margin area. * - * Only affects `vRelativeFrom === 'page'` anchored image/drawing fragments. + * Only affects anchored image/drawing fragments whose anchor is page-relative. * Paragraphs, inline images, and margin-relative anchors pass through unchanged. */ export function normalizeFragmentsForRegion( @@ -95,7 +118,8 @@ export function normalizeFragmentsForRegion( } const pageHeight = constraints.pageHeight; - const bandOrigin = computeFooterBandOrigin(constraints); + const pageWidth = constraints.pageWidth; + const bandOrigin = computeBandOrigin(_kind, constraints); const blockById = new Map(); for (const block of blocks) { @@ -109,9 +133,16 @@ export function normalizeFragmentsForRegion( const block = blockById.get(fragment.blockId); if (!block || !isPageRelativeBlock(block)) continue; - const fragmentHeight = (fragment as { height?: number }).height ?? 0; - const physicalY = computePhysicalAnchorY(block, fragmentHeight, pageHeight); - fragment.y = physicalY - bandOrigin; + if (block.anchor?.hRelativeFrom === 'page' && pageWidth != null) { + const fragmentWidth = (fragment as { width?: number }).width ?? 0; + fragment.x = computePhysicalAnchorX(block, fragmentWidth, pageWidth); + } + + if (block.anchor?.vRelativeFrom === 'page') { + const fragmentHeight = (fragment as { height?: number }).height ?? 0; + const physicalY = computePhysicalAnchorY(block, fragmentHeight, pageHeight); + fragment.y = physicalY - bandOrigin; + } } } From 5fb043913abb18d75aa9829aec97e502b57684e2 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 12 Jun 2026 17:35:35 -0300 Subject: [PATCH 09/30] fix: keep straight connector arrows uniform --- .../src/renderer-shape-regressions.test.ts | 55 +++++++++++++++ .../painters/dom/src/renderer.ts | 18 +++-- .../vector-shape/VectorShapeView.js | 17 +++-- .../vector-shape/VectorShapeView.test.js | 69 +++++++++++++++++++ 4 files changed, 151 insertions(+), 8 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts b/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts index 68c07a0a20..3f16c5c81e 100644 --- a/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts @@ -153,6 +153,61 @@ describe('DomPainter shape regressions', () => { expect(marker?.getAttribute('markerHeight')).toBe('8'); }); + it('renders straight connector strokes without scaling them thinner than connector presets', () => { + const geometry: DrawingGeometry = { width: 450, height: 16, rotation: 0, flipH: false, flipV: false }; + const drawingBlock: DrawingFlowBlock = { + kind: 'drawing', + id: 'straight-connector-uniform-stroke', + drawingKind: 'vectorShape', + geometry, + effectExtent: { left: 0, top: 7, right: 0, bottom: 8 }, + shapeKind: 'straightConnector1', + fillColor: null, + strokeColor: '#5b9bd5', + strokeWidth: 1, + lineEnds: { + tail: { type: 'triangle' }, + }, + }; + + const { blocks, measures, layout } = createDrawingFixtures(drawingBlock); + const painter = createDomPainter({ blocks, measures }); + painter.paint(layout, mount); + + const svg = mount.querySelector('.superdoc-vector-shape svg') as SVGSVGElement | null; + const line = svg?.querySelector('line') as SVGLineElement | null; + expect(svg?.getAttribute('viewBox')).toBe('0 0 450 1'); + expect(svg?.getAttribute('preserveAspectRatio')).toBe('none'); + expect(line?.getAttribute('y1')).toBe('0.5'); + expect(line?.getAttribute('y2')).toBe('0.5'); + expect(line?.getAttribute('stroke-width')).toBe('1'); + expect(line?.getAttribute('vector-effect')).toBe('non-scaling-stroke'); + }); + + it('preserves tiny square straight connectors as diagonal lines', () => { + const geometry: DrawingGeometry = { width: 1, height: 1, rotation: 0, flipH: false, flipV: false }; + const drawingBlock: DrawingFlowBlock = { + kind: 'drawing', + id: 'straight-connector-tiny-diagonal', + drawingKind: 'vectorShape', + geometry, + shapeKind: 'straightConnector1', + fillColor: null, + strokeColor: '#5b9bd5', + strokeWidth: 1, + }; + + const { blocks, measures, layout } = createDrawingFixtures(drawingBlock); + const painter = createDomPainter({ blocks, measures }); + painter.paint(layout, mount); + + const line = mount.querySelector('.superdoc-vector-shape svg line') as SVGLineElement | null; + expect(line?.getAttribute('x1')).toBe('0'); + expect(line?.getAttribute('y1')).toBe('0'); + expect(line?.getAttribute('x2')).toBe('1'); + expect(line?.getAttribute('y2')).toBe('1'); + }); + it('renders DrawingML headEnd on the visual start of a straight connector', () => { const geometry: DrawingGeometry = { width: 450, height: 1, rotation: 0, flipH: false, flipV: false }; const drawingBlock: DrawingFlowBlock = { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index dd6d308267..d9a081524e 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3231,7 +3231,7 @@ export class DomPainter { if (resolvedSvgMarkup) { const svgElement = this.parseSafeSvg(resolvedSvgMarkup); if (svgElement) { - if (!customGeomSvg && this.isConnectorPresetShape(block.shapeKind)) { + if (!customGeomSvg && (this.isConnectorPresetShape(block.shapeKind) || this.isLineLikeShape(block.shapeKind))) { this.applyNonScalingStrokeToConnector(svgElement); } else { this.expandSvgViewBoxForCenteredStroke(svgElement); @@ -3329,6 +3329,10 @@ export class DomPainter { return typeof shapeKind === 'string' && CONNECTOR_PRESET_SHAPES.has(shapeKind); } + private isLineLikeShape(shapeKind?: string | null): boolean { + return shapeKind === 'line' || shapeKind === 'straightConnector1'; + } + private applyNonScalingStrokeToConnector(svgElement: SVGElement): void { svgElement.querySelectorAll(CONNECTOR_SVG_ELEMENTS).forEach((target) => { const stroke = target.getAttribute('stroke'); @@ -3777,9 +3781,15 @@ export class DomPainter { const height = heightOverride ?? block.geometry.height; const stroke = strokeColor ?? '#000000'; const strokeWidth = block.strokeWidth ?? 1; - - return ` - + const isHorizontal = height <= 1 && width > height; + const isVertical = width <= 1 && height > width; + const x1 = isVertical ? width / 2 : 0; + const y1 = isHorizontal ? height / 2 : 0; + const x2 = isVertical ? width / 2 : width; + const y2 = isHorizontal ? height / 2 : height; + + return ` + `; } diff --git a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js index fc7957d07d..05b76dd882 100644 --- a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js +++ b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js @@ -394,6 +394,8 @@ export class VectorShapeView { svg.setAttribute('width', width.toString()); svg.setAttribute('height', height.toString()); svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + svg.style.width = `${width}px`; + svg.style.height = `${height}px`; svg.style.display = 'block'; // Create defs for gradients if needed @@ -467,11 +469,13 @@ export class VectorShapeView { case 'line': case 'straightConnector1': + const isHorizontalLine = height <= 1 && width > height; + const isVerticalLine = width <= 1 && height > width; shapeElement = document.createElementNS('http://www.w3.org/2000/svg', 'line'); - shapeElement.setAttribute('x1', '0'); - shapeElement.setAttribute('y1', '0'); - shapeElement.setAttribute('x2', width.toString()); - shapeElement.setAttribute('y2', height.toString()); + shapeElement.setAttribute('x1', isVerticalLine ? (width / 2).toString() : '0'); + shapeElement.setAttribute('y1', isHorizontalLine ? (height / 2).toString() : '0'); + shapeElement.setAttribute('x2', isVerticalLine ? (width / 2).toString() : width.toString()); + shapeElement.setAttribute('y2', isHorizontalLine ? (height / 2).toString() : height.toString()); break; default: @@ -512,6 +516,11 @@ export class VectorShapeView { } shapeElement.setAttribute('stroke', stroke); shapeElement.setAttribute('stroke-width', strokeW.toString()); + if (kind === 'line' || kind === 'straightConnector1') { + svg.setAttribute('viewBox', `0 0 ${formatSvgNumber(width)} ${formatSvgNumber(height)}`); + svg.setAttribute('preserveAspectRatio', 'none'); + shapeElement.setAttribute('vector-effect', 'non-scaling-stroke'); + } svg.appendChild(shapeElement); return svg; diff --git a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.test.js b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.test.js index 2c6f3f2ef4..9c3e2cfce5 100644 --- a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.test.js +++ b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.test.js @@ -445,6 +445,75 @@ describe('VectorShapeView', () => { expect(path.getAttribute('marker-end')).toContain('tail'); expect(marker.getAttribute('markerUnits')).toBe('strokeWidth'); }); + + it('renders straight connector strokes as non-scaling inside an unscaled base viewBox', () => { + const connectorNode = { + attrs: { + kind: 'straightConnector1', + width: 450, + height: 1, + effectExtent: { left: 0, top: 7, right: 0, bottom: 8 }, + fillColor: null, + strokeColor: '#5b9bd5', + strokeWidth: 1, + lineEnds: { + tail: { type: 'triangle' }, + }, + }, + }; + + const view = new VectorShapeView({ + node: connectorNode, + editor: mockEditor, + getPos: mockGetPos, + decorations: [], + innerDecorations: [], + extension: {}, + htmlAttributes: {}, + }); + + const svg = view.dom.querySelector('svg'); + const line = svg.querySelector('line'); + expect(view.dom.style.height).toBe('16px'); + expect(svg.getAttribute('viewBox')).toBe('0 0 450 1'); + expect(svg.getAttribute('preserveAspectRatio')).toBe('none'); + expect(svg.style.width).toBe('450px'); + expect(svg.style.height).toBe('1px'); + expect(line.getAttribute('y1')).toBe('0.5'); + expect(line.getAttribute('y2')).toBe('0.5'); + expect(line.getAttribute('stroke-width')).toBe('1'); + expect(line.getAttribute('vector-effect')).toBe('non-scaling-stroke'); + expect(line.getAttribute('marker-end')).toContain('tail'); + }); + + it('preserves tiny square straight connectors as diagonal lines', () => { + const connectorNode = { + attrs: { + kind: 'straightConnector1', + width: 1, + height: 1, + fillColor: null, + strokeColor: '#5b9bd5', + strokeWidth: 1, + }, + }; + + const view = new VectorShapeView({ + node: connectorNode, + editor: mockEditor, + getPos: mockGetPos, + decorations: [], + innerDecorations: [], + extension: {}, + htmlAttributes: {}, + }); + + const line = view.dom.querySelector('svg line'); + expect(line.getAttribute('x1')).toBe('0'); + expect(line.getAttribute('y1')).toBe('0'); + expect(line.getAttribute('x2')).toBe('1'); + expect(line.getAttribute('y2')).toBe('1'); + }); }); describe('edge cases and error handling', () => { From c2e7ee651d64420e1fc1d67135d5115b754accc8 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 15 Jun 2026 10:08:37 -0300 Subject: [PATCH 10/30] fix: share connector SVG helpers --- .../painters/dom/src/renderer.ts | 140 ++-------------- packages/preset-geometry/connectors.d.ts | 32 ++++ packages/preset-geometry/connectors.js | 140 ++++++++++++++++ packages/preset-geometry/package.json | 4 + .../extensions/shape-group/ShapeGroupView.js | 146 ++-------------- .../shape-group/ShapeGroupView.test.js | 64 +++++++ .../editors/v1/extensions/shared/svg-utils.js | 29 ++++ .../v1/extensions/shared/svg-utils.test.js | 42 ++++- .../vector-shape/VectorShapeView.js | 158 +----------------- .../vector-shape/VectorShapeView.test.js | 18 +- 10 files changed, 357 insertions(+), 416 deletions(-) create mode 100644 packages/preset-geometry/connectors.d.ts create mode 100644 packages/preset-geometry/connectors.js create mode 100644 packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.test.js diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index d9a081524e..7573e7d46e 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -65,6 +65,12 @@ import { } from '@superdoc/contracts'; import { DATASET_KEYS, decodeLayoutStoryDataset, encodeLayoutStoryDataset } from '@superdoc/dom-contract'; import { getPresetShapeSvg } from '@superdoc/preset-geometry'; +import { + applyNonScalingStrokeToConnector, + createConnectorPresetSvg, + createLineEndMarker as createSharedLineEndMarker, + isConnectorPresetShape, +} from '@superdoc/preset-geometry/connectors'; import { DOM_CLASS_NAMES } from './constants.js'; import { createChartElement as renderChartToElement } from './chart-renderer.js'; import { createRulerElement, ensureRulerStyles, generateRulerDefinitionFromPx } from './ruler/index.js'; @@ -867,17 +873,6 @@ const DEFAULT_VIRTUALIZED_PAGE_GAP = 72; const PAGE_BACKGROUND_OVERLAY_Z_ORDER_OFFSET = 1_000_000; const SVG_NS = 'http://www.w3.org/2000/svg'; const WORDART_LINE_FILL_RATIO = 0.9; -const CONNECTOR_PRESET_SHAPES = new Set([ - 'bentConnector2', - 'bentConnector3', - 'bentConnector4', - 'bentConnector5', - 'curvedConnector2', - 'curvedConnector3', - 'curvedConnector4', - 'curvedConnector5', -]); -const CONNECTOR_SVG_ELEMENTS = 'path, line, polyline'; // Comment highlight color tokens moved to CommentHighlightDecorator (super-editor). /** @@ -3231,8 +3226,8 @@ export class DomPainter { if (resolvedSvgMarkup) { const svgElement = this.parseSafeSvg(resolvedSvgMarkup); if (svgElement) { - if (!customGeomSvg && (this.isConnectorPresetShape(block.shapeKind) || this.isLineLikeShape(block.shapeKind))) { - this.applyNonScalingStrokeToConnector(svgElement); + if (!customGeomSvg && (isConnectorPresetShape(block.shapeKind) || this.isLineLikeShape(block.shapeKind))) { + applyNonScalingStrokeToConnector(svgElement); } else { this.expandSvgViewBoxForCenteredStroke(svgElement); } @@ -3325,22 +3320,10 @@ export class DomPainter { } } - private isConnectorPresetShape(shapeKind?: string | null): boolean { - return typeof shapeKind === 'string' && CONNECTOR_PRESET_SHAPES.has(shapeKind); - } - private isLineLikeShape(shapeKind?: string | null): boolean { return shapeKind === 'line' || shapeKind === 'straightConnector1'; } - private applyNonScalingStrokeToConnector(svgElement: SVGElement): void { - svgElement.querySelectorAll(CONNECTOR_SVG_ELEMENTS).forEach((target) => { - const stroke = target.getAttribute('stroke'); - if (!stroke || stroke === 'none') return; - target.setAttribute('vector-effect', 'non-scaling-stroke'); - }); - } - private hasShapeTextContent(textContent?: ShapeTextContent): textContent is ShapeTextContent { return Array.isArray(textContent?.parts) && textContent.parts.length > 0; } @@ -3793,7 +3776,7 @@ export class DomPainter { `; } - if (this.isConnectorPresetShape(block.shapeKind)) { + if (isConnectorPresetShape(block.shapeKind)) { const connectorSvg = this.tryCreateConnectorPresetSvg( block, widthOverride ?? block.geometry.width, @@ -3884,56 +3867,16 @@ export class DomPainter { width: number, height: number, ): string | null { - const pathD = this.getConnectorPresetPath(block.shapeKind, width, height); - if (!pathD) return null; - const stroke = block.strokeColor === null ? 'none' : typeof block.strokeColor === 'string' ? block.strokeColor : '#000000'; const strokeWidth = block.strokeWidth ?? 1; - const formattedWidth = this.formatSvgNumber(width); - const formattedHeight = this.formatSvgNumber(height); - const strokePadding = stroke !== 'none' && strokeWidth > 0 ? strokeWidth / 2 : 0; - const viewBoxX = this.formatSvgNumber(-strokePadding); - const viewBoxY = this.formatSvgNumber(-strokePadding); - const viewBoxWidth = this.formatSvgNumber(width + strokePadding * 2); - const viewBoxHeight = this.formatSvgNumber(height + strokePadding * 2); - - return ` - -`; - } - - private getConnectorPresetPath(shapeKind: string | null | undefined, width: number, height: number): string | null { - const w = Math.max(0, width); - const h = Math.max(0, height); - const xMid = w / 2; - const yMid = h / 2; - const xQuarter = w * 0.25; - const xThreeQuarter = w * 0.75; - const yQuarter = h * 0.25; - const yThreeQuarter = h * 0.75; - const fmt = (value: number): string => this.formatSvgNumber(value); - - switch (shapeKind) { - case 'bentConnector2': - return `M 0 0 L ${fmt(w)} 0 L ${fmt(w)} ${fmt(h)}`; - case 'bentConnector3': - return `M 0 0 L ${fmt(xMid)} 0 L ${fmt(xMid)} ${fmt(h)} L ${fmt(w)} ${fmt(h)}`; - case 'bentConnector4': - return `M 0 0 L ${fmt(xMid)} 0 L ${fmt(xMid)} ${fmt(yMid)} L ${fmt(w)} ${fmt(yMid)} L ${fmt(w)} ${fmt(h)}`; - case 'bentConnector5': - return `M 0 0 L ${fmt(xMid)} 0 L ${fmt(xMid)} ${fmt(yMid)} L ${fmt(xMid)} ${fmt(yMid)} L ${fmt(xMid)} ${fmt(h)} L ${fmt(w)} ${fmt(h)}`; - case 'curvedConnector2': - return `M 0 0 C ${fmt(xMid)} 0 ${fmt(w)} ${fmt(yMid)} ${fmt(w)} ${fmt(h)}`; - case 'curvedConnector3': - return `M 0 0 C ${fmt(xQuarter)} 0 ${fmt(xMid)} ${fmt(yQuarter)} ${fmt(xMid)} ${fmt(yMid)} C ${fmt(xMid)} ${fmt(yThreeQuarter)} ${fmt(xThreeQuarter)} ${fmt(h)} ${fmt(w)} ${fmt(h)}`; - case 'curvedConnector4': - return `M 0 0 C ${fmt(xQuarter)} 0 ${fmt(xMid)} ${fmt(h * 0.125)} ${fmt(xMid)} ${fmt(yQuarter)} C ${fmt(xMid)} ${fmt(h * 0.375)} ${fmt(w * 0.625)} ${fmt(yMid)} ${fmt(xThreeQuarter)} ${fmt(yMid)} C ${fmt(w * 0.875)} ${fmt(yMid)} ${fmt(w)} ${fmt(yThreeQuarter)} ${fmt(w)} ${fmt(h)}`; - case 'curvedConnector5': - return `M 0 0 C ${fmt(xQuarter)} 0 ${fmt(xMid)} ${fmt(h * 0.125)} ${fmt(xMid)} ${fmt(yQuarter)} C ${fmt(xMid)} ${fmt(h * 0.375)} ${fmt(xMid)} ${fmt(yMid)} ${fmt(xMid)} ${fmt(yMid)} C ${fmt(xMid)} ${fmt(yMid)} ${fmt(xMid)} ${fmt(h * 0.625)} ${fmt(xMid)} ${fmt(yThreeQuarter)} C ${fmt(xMid)} ${fmt(h * 0.875)} ${fmt(xThreeQuarter)} ${fmt(h)} ${fmt(w)} ${fmt(h)}`; - default: - return null; - } + return createConnectorPresetSvg({ + kind: block.shapeKind, + strokeColor: stroke, + strokeWidth, + width, + height, + }); } /** @@ -4235,56 +4178,7 @@ export class DomPainter { _strokeWidth: number, isStart: boolean, ): void { - if (defs.querySelector(`#${id}`)) return; - - const marker = this.doc!.createElementNS('http://www.w3.org/2000/svg', 'marker'); - marker.setAttribute('id', id); - marker.setAttribute('viewBox', '0 0 10 10'); - marker.setAttribute('orient', 'auto'); - - const sizeScale = (value?: string): number => { - if (value === 'sm') return 0.75; - if (value === 'lg') return 1.25; - return 1; - }; - const markerWidth = 8 * sizeScale(lineEnd.length); - const markerHeight = 8 * sizeScale(lineEnd.width); - marker.setAttribute('markerUnits', 'strokeWidth'); - marker.setAttribute('markerWidth', markerWidth.toString()); - marker.setAttribute('markerHeight', markerHeight.toString()); - marker.setAttribute('refX', isStart ? '0' : '10'); - marker.setAttribute('refY', '5'); - - const shape = this.createLineEndShape(lineEnd.type ?? 'triangle', strokeColor, isStart); - marker.appendChild(shape); - defs.appendChild(marker); - } - - private createLineEndShape(type: string, strokeColor: string, isStart: boolean): SVGElement { - const normalized = type.toLowerCase(); - if (normalized === 'diamond') { - const path = this.doc!.createElementNS('http://www.w3.org/2000/svg', 'path'); - path.setAttribute('d', 'M 0 5 L 5 0 L 10 5 L 5 10 Z'); - path.setAttribute('fill', strokeColor); - path.setAttribute('stroke', 'none'); - return path; - } - if (normalized === 'oval') { - const circle = this.doc!.createElementNS('http://www.w3.org/2000/svg', 'circle'); - circle.setAttribute('cx', '5'); - circle.setAttribute('cy', '5'); - circle.setAttribute('r', '5'); - circle.setAttribute('fill', strokeColor); - circle.setAttribute('stroke', 'none'); - return circle; - } - - const path = this.doc!.createElementNS('http://www.w3.org/2000/svg', 'path'); - const d = isStart ? 'M 10 0 L 0 5 L 10 10 Z' : 'M 0 0 L 10 5 L 0 10 Z'; - path.setAttribute('d', d); - path.setAttribute('fill', strokeColor); - path.setAttribute('stroke', 'none'); - return path; + createSharedLineEndMarker(this.doc!, defs, id, lineEnd, strokeColor, _strokeWidth, isStart); } private sanitizeSvgId(value: string): string { diff --git a/packages/preset-geometry/connectors.d.ts b/packages/preset-geometry/connectors.d.ts new file mode 100644 index 0000000000..f112246e2b --- /dev/null +++ b/packages/preset-geometry/connectors.d.ts @@ -0,0 +1,32 @@ +export type LineEnd = { + type?: string; + width?: string; + length?: string; +}; + +export type ConnectorSvgOptions = { + kind?: string | null; + strokeColor?: string | null; + strokeWidth?: number; + width: number; + height: number; +}; + +export const CONNECTOR_PRESET_SHAPES: Set; +export function isConnectorPresetShape(kind: unknown): boolean; +export function formatSvgNumber(value: number): string; +export function getConnectorPresetPath(kind: string | null | undefined, width: number, height: number): string | null; +export function getConnectorStrokePadding(strokeColor: string | null | undefined, strokeWidth: number): number; +export function createConnectorPresetSvg(options: ConnectorSvgOptions): string | null; +export function applyNonScalingStrokeToConnectorTarget(target: SVGElement): void; +export function applyNonScalingStrokeToConnector(svgElement: SVGElement): void; +export function createLineEndShape(doc: Document, type: string, strokeColor: string, isStart: boolean): SVGElement; +export function createLineEndMarker( + doc: Document, + defs: SVGDefsElement, + id: string, + lineEnd: LineEnd, + strokeColor: string, + strokeWidth: number, + isStart: boolean, +): void; diff --git a/packages/preset-geometry/connectors.js b/packages/preset-geometry/connectors.js new file mode 100644 index 0000000000..49838e44a5 --- /dev/null +++ b/packages/preset-geometry/connectors.js @@ -0,0 +1,140 @@ +const SVG_NS = 'http://www.w3.org/2000/svg'; +const CONNECTOR_SVG_ELEMENTS = 'path, line, polyline'; + +export const CONNECTOR_PRESET_SHAPES = new Set([ + 'bentConnector2', + 'bentConnector3', + 'bentConnector4', + 'bentConnector5', + 'curvedConnector2', + 'curvedConnector3', + 'curvedConnector4', + 'curvedConnector5', +]); + +export function isConnectorPresetShape(kind) { + return typeof kind === 'string' && CONNECTOR_PRESET_SHAPES.has(kind); +} + +export function formatSvgNumber(value) { + return Number.isFinite(value) ? Number(value.toFixed(4)).toString() : '0'; +} + +export function getConnectorPresetPath(kind, width, height) { + const w = Math.max(0, width); + const h = Math.max(0, height); + const xMid = w / 2; + const yMid = h / 2; + const xQuarter = w * 0.25; + const xThreeQuarter = w * 0.75; + const yQuarter = h * 0.25; + const yThreeQuarter = h * 0.75; + const fmt = formatSvgNumber; + + switch (kind) { + case 'bentConnector2': + return `M 0 0 L ${fmt(w)} 0 L ${fmt(w)} ${fmt(h)}`; + case 'bentConnector3': + return `M 0 0 L ${fmt(xMid)} 0 L ${fmt(xMid)} ${fmt(h)} L ${fmt(w)} ${fmt(h)}`; + case 'bentConnector4': + return `M 0 0 L ${fmt(xMid)} 0 L ${fmt(xMid)} ${fmt(yMid)} L ${fmt(w)} ${fmt(yMid)} L ${fmt(w)} ${fmt(h)}`; + case 'bentConnector5': + return `M 0 0 L ${fmt(xQuarter)} 0 L ${fmt(xQuarter)} ${fmt(yMid)} L ${fmt(xThreeQuarter)} ${fmt(yMid)} L ${fmt(xThreeQuarter)} ${fmt(h)} L ${fmt(w)} ${fmt(h)}`; + case 'curvedConnector2': + return `M 0 0 C ${fmt(xMid)} 0 ${fmt(w)} ${fmt(yMid)} ${fmt(w)} ${fmt(h)}`; + case 'curvedConnector3': + return `M 0 0 C ${fmt(xQuarter)} 0 ${fmt(xMid)} ${fmt(yQuarter)} ${fmt(xMid)} ${fmt(yMid)} C ${fmt(xMid)} ${fmt(yThreeQuarter)} ${fmt(xThreeQuarter)} ${fmt(h)} ${fmt(w)} ${fmt(h)}`; + case 'curvedConnector4': + return `M 0 0 C ${fmt(xQuarter)} 0 ${fmt(xMid)} ${fmt(h * 0.125)} ${fmt(xMid)} ${fmt(yQuarter)} C ${fmt(xMid)} ${fmt(h * 0.375)} ${fmt(w * 0.625)} ${fmt(yMid)} ${fmt(xThreeQuarter)} ${fmt(yMid)} C ${fmt(w * 0.875)} ${fmt(yMid)} ${fmt(w)} ${fmt(yThreeQuarter)} ${fmt(w)} ${fmt(h)}`; + case 'curvedConnector5': + return `M 0 0 C ${fmt(xQuarter * 0.5)} 0 ${fmt(xQuarter)} ${fmt(yQuarter * 0.5)} ${fmt(xQuarter)} ${fmt(yQuarter)} C ${fmt(xQuarter)} ${fmt(yMid * 0.75)} ${fmt(xQuarter)} ${fmt(yMid)} ${fmt(xMid)} ${fmt(yMid)} C ${fmt(xThreeQuarter)} ${fmt(yMid)} ${fmt(xThreeQuarter)} ${fmt(yMid * 1.25)} ${fmt(xThreeQuarter)} ${fmt(yThreeQuarter)} C ${fmt(xThreeQuarter)} ${fmt(h * 0.875)} ${fmt(xThreeQuarter + xQuarter * 0.5)} ${fmt(h)} ${fmt(w)} ${fmt(h)}`; + default: + return null; + } +} + +export function getConnectorStrokePadding(strokeColor, strokeWidth) { + return strokeColor !== null && strokeWidth > 0 ? strokeWidth / 2 : 0; +} + +export function createConnectorPresetSvg({ kind, strokeColor, strokeWidth, width, height }) { + const pathD = getConnectorPresetPath(kind, width, height); + if (!pathD) return null; + + const stroke = strokeColor === null ? 'none' : strokeColor || '#000000'; + const resolvedStrokeWidth = strokeWidth ?? 1; + const formattedWidth = formatSvgNumber(width); + const formattedHeight = formatSvgNumber(height); + const strokePadding = getConnectorStrokePadding(strokeColor, resolvedStrokeWidth); + const viewBoxX = formatSvgNumber(-strokePadding); + const viewBoxY = formatSvgNumber(-strokePadding); + const viewBoxWidth = formatSvgNumber(width + strokePadding * 2); + const viewBoxHeight = formatSvgNumber(height + strokePadding * 2); + + return ` + +`; +} + +export function applyNonScalingStrokeToConnectorTarget(target) { + const stroke = target.getAttribute('stroke'); + if (!stroke || stroke === 'none') return; + target.setAttribute('vector-effect', 'non-scaling-stroke'); +} + +export function applyNonScalingStrokeToConnector(svgElement) { + svgElement.querySelectorAll(CONNECTOR_SVG_ELEMENTS).forEach(applyNonScalingStrokeToConnectorTarget); +} + +export function createLineEndShape(doc, type, strokeColor, isStart) { + const normalized = type.toLowerCase(); + if (normalized === 'diamond') { + const path = doc.createElementNS(SVG_NS, 'path'); + path.setAttribute('d', 'M 0 5 L 5 0 L 10 5 L 5 10 Z'); + path.setAttribute('fill', strokeColor); + path.setAttribute('stroke', 'none'); + return path; + } + if (normalized === 'oval') { + const circle = doc.createElementNS(SVG_NS, 'circle'); + circle.setAttribute('cx', '5'); + circle.setAttribute('cy', '5'); + circle.setAttribute('r', '5'); + circle.setAttribute('fill', strokeColor); + circle.setAttribute('stroke', 'none'); + return circle; + } + + const path = doc.createElementNS(SVG_NS, 'path'); + const d = isStart ? 'M 10 0 L 0 5 L 10 10 Z' : 'M 0 0 L 10 5 L 0 10 Z'; + path.setAttribute('d', d); + path.setAttribute('fill', strokeColor); + path.setAttribute('stroke', 'none'); + return path; +} + +export function createLineEndMarker(doc, defs, id, lineEnd, strokeColor, _strokeWidth, isStart) { + if (defs.querySelector(`#${id}`)) return; + + const marker = doc.createElementNS(SVG_NS, 'marker'); + marker.setAttribute('id', id); + marker.setAttribute('viewBox', '0 0 10 10'); + marker.setAttribute('orient', 'auto'); + + const sizeScale = (value) => { + if (value === 'sm') return 0.75; + if (value === 'lg') return 1.25; + return 1; + }; + const markerWidth = 8 * sizeScale(lineEnd.length); + const markerHeight = 8 * sizeScale(lineEnd.width); + marker.setAttribute('markerUnits', 'strokeWidth'); + marker.setAttribute('markerWidth', markerWidth.toString()); + marker.setAttribute('markerHeight', markerHeight.toString()); + marker.setAttribute('refX', isStart ? '0' : '10'); + marker.setAttribute('refY', '5'); + + const shape = createLineEndShape(doc, lineEnd.type || 'triangle', strokeColor, isStart); + marker.appendChild(shape); + defs.appendChild(marker); +} diff --git a/packages/preset-geometry/package.json b/packages/preset-geometry/package.json index ca1f05a0b3..6b5d329c2a 100644 --- a/packages/preset-geometry/package.json +++ b/packages/preset-geometry/package.json @@ -10,6 +10,10 @@ ".": { "types": "./index.d.ts", "default": "./index.js" + }, + "./connectors": { + "types": "./connectors.d.ts", + "default": "./connectors.js" } } } diff --git a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js index 3de58b3dbb..f23050deb1 100644 --- a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js +++ b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js @@ -1,69 +1,17 @@ // @ts-expect-error - preset-geometry package may not have type definitions import { getPresetShapeSvg } from '@superdoc/preset-geometry'; -import { createGradient, createTextElement } from '../shared/svg-utils.js'; +import { + applyNonScalingStrokeToConnectorTarget, + createGradient, + createLineEndMarker, + createTextElement, + getConnectorPresetPath, + getConnectorStrokePadding, + isConnectorPresetShape, + formatSvgNumber, +} from '../shared/svg-utils.js'; import { OOXML_Z_INDEX_BASE } from '@extensions/shared/constants.js'; -const CONNECTOR_PRESET_SHAPES = new Set([ - 'bentConnector2', - 'bentConnector3', - 'bentConnector4', - 'bentConnector5', - 'curvedConnector2', - 'curvedConnector3', - 'curvedConnector4', - 'curvedConnector5', -]); -function isConnectorPresetShape(kind) { - return typeof kind === 'string' && CONNECTOR_PRESET_SHAPES.has(kind); -} - -function applyNonScalingStrokeToConnectorTarget(target) { - const stroke = target.getAttribute('stroke'); - if (!stroke || stroke === 'none') return; - target.setAttribute('vector-effect', 'non-scaling-stroke'); -} - -function formatSvgNumber(value) { - return Number.isFinite(value) ? Number(value.toFixed(4)).toString() : '0'; -} - -function getConnectorPresetPath(kind, width, height) { - const w = Math.max(0, width); - const h = Math.max(0, height); - const xMid = w / 2; - const yMid = h / 2; - const xQuarter = w * 0.25; - const xThreeQuarter = w * 0.75; - const yQuarter = h * 0.25; - const yThreeQuarter = h * 0.75; - const fmt = formatSvgNumber; - - switch (kind) { - case 'bentConnector2': - return `M 0 0 L ${fmt(w)} 0 L ${fmt(w)} ${fmt(h)}`; - case 'bentConnector3': - return `M 0 0 L ${fmt(xMid)} 0 L ${fmt(xMid)} ${fmt(h)} L ${fmt(w)} ${fmt(h)}`; - case 'bentConnector4': - return `M 0 0 L ${fmt(xMid)} 0 L ${fmt(xMid)} ${fmt(yMid)} L ${fmt(w)} ${fmt(yMid)} L ${fmt(w)} ${fmt(h)}`; - case 'bentConnector5': - return `M 0 0 L ${fmt(xMid)} 0 L ${fmt(xMid)} ${fmt(yMid)} L ${fmt(xMid)} ${fmt(yMid)} L ${fmt(xMid)} ${fmt(h)} L ${fmt(w)} ${fmt(h)}`; - case 'curvedConnector2': - return `M 0 0 C ${fmt(xMid)} 0 ${fmt(w)} ${fmt(yMid)} ${fmt(w)} ${fmt(h)}`; - case 'curvedConnector3': - return `M 0 0 C ${fmt(xQuarter)} 0 ${fmt(xMid)} ${fmt(yQuarter)} ${fmt(xMid)} ${fmt(yMid)} C ${fmt(xMid)} ${fmt(yThreeQuarter)} ${fmt(xThreeQuarter)} ${fmt(h)} ${fmt(w)} ${fmt(h)}`; - case 'curvedConnector4': - return `M 0 0 C ${fmt(xQuarter)} 0 ${fmt(xMid)} ${fmt(h * 0.125)} ${fmt(xMid)} ${fmt(yQuarter)} C ${fmt(xMid)} ${fmt(h * 0.375)} ${fmt(w * 0.625)} ${fmt(yMid)} ${fmt(xThreeQuarter)} ${fmt(yMid)} C ${fmt(w * 0.875)} ${fmt(yMid)} ${fmt(w)} ${fmt(yThreeQuarter)} ${fmt(w)} ${fmt(h)}`; - case 'curvedConnector5': - return `M 0 0 C ${fmt(xQuarter)} 0 ${fmt(xMid)} ${fmt(h * 0.125)} ${fmt(xMid)} ${fmt(yQuarter)} C ${fmt(xMid)} ${fmt(h * 0.375)} ${fmt(xMid)} ${fmt(yMid)} ${fmt(xMid)} ${fmt(yMid)} C ${fmt(xMid)} ${fmt(yMid)} ${fmt(xMid)} ${fmt(h * 0.625)} ${fmt(xMid)} ${fmt(yThreeQuarter)} C ${fmt(xMid)} ${fmt(h * 0.875)} ${fmt(xThreeQuarter)} ${fmt(h)} ${fmt(w)} ${fmt(h)}`; - default: - return null; - } -} - -function getConnectorStrokePadding(strokeColor, strokeWidth) { - return strokeColor !== null && strokeWidth > 0 ? strokeWidth / 2 : 0; -} - export class ShapeGroupView { node; @@ -666,87 +614,17 @@ export class ShapeGroupView { if (lineEnds.head) { const id = `${markerBase}-head`; - this.createLineEndMarker(defs, id, lineEnds.head, strokeColor, strokeWidth, true); + createLineEndMarker(defs, id, lineEnds.head, strokeColor, strokeWidth, true); target.setAttribute('marker-start', `url(#${id})`); } if (lineEnds.tail) { const id = `${markerBase}-tail`; - this.createLineEndMarker(defs, id, lineEnds.tail, strokeColor, strokeWidth, false); + createLineEndMarker(defs, id, lineEnds.tail, strokeColor, strokeWidth, false); target.setAttribute('marker-end', `url(#${id})`); } } - /** - * Creates an SVG marker element for a line end (arrowhead). - * @param {SVGDefsElement} defs - The defs element to append the marker to - * @param {string} id - Unique ID for the marker - * @param {Object} lineEnd - Line end configuration with type, width, length - * @param {string} strokeColor - Color to use for the marker fill - * @param {number} _strokeWidth - Stroke width (currently unused, reserved for future scaling) - * @param {boolean} isStart - Whether this is a start marker (head) or end marker (tail) - */ - createLineEndMarker(defs, id, lineEnd, strokeColor, _strokeWidth, isStart) { - if (defs.querySelector(`#${id}`)) return; - - const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); - marker.setAttribute('id', id); - marker.setAttribute('viewBox', '0 0 10 10'); - marker.setAttribute('orient', 'auto'); - - const sizeScale = (value) => { - if (value === 'sm') return 0.75; - if (value === 'lg') return 1.25; - return 1; - }; - const markerWidth = 8 * sizeScale(lineEnd.length); - const markerHeight = 8 * sizeScale(lineEnd.width); - marker.setAttribute('markerUnits', 'strokeWidth'); - marker.setAttribute('markerWidth', markerWidth.toString()); - marker.setAttribute('markerHeight', markerHeight.toString()); - marker.setAttribute('refX', isStart ? '0' : '10'); - marker.setAttribute('refY', '5'); - - const shape = this.createLineEndShape(lineEnd.type || 'triangle', strokeColor, isStart); - marker.appendChild(shape); - defs.appendChild(marker); - } - - /** - * Creates an SVG shape element for a line end marker. - * Supports diamond, oval, and triangle (default) shapes. - * @param {string} type - The shape type ('diamond', 'oval', or 'triangle') - * @param {string} strokeColor - Color to fill the shape with - * @param {boolean} isStart - Whether this is a start marker (affects triangle orientation) - * @returns {SVGElement} The created SVG shape element - */ - createLineEndShape(type, strokeColor, isStart) { - const normalized = type.toLowerCase(); - if (normalized === 'diamond') { - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - path.setAttribute('d', 'M 0 5 L 5 0 L 10 5 L 5 10 Z'); - path.setAttribute('fill', strokeColor); - path.setAttribute('stroke', 'none'); - return path; - } - if (normalized === 'oval') { - const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - circle.setAttribute('cx', '5'); - circle.setAttribute('cy', '5'); - circle.setAttribute('r', '5'); - circle.setAttribute('fill', strokeColor); - circle.setAttribute('stroke', 'none'); - return circle; - } - - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - const d = isStart ? 'M 10 0 L 0 5 L 10 10 Z' : 'M 0 0 L 10 5 L 0 10 Z'; - path.setAttribute('d', d); - path.setAttribute('fill', strokeColor); - path.setAttribute('stroke', 'none'); - return path; - } - createGradient(gradientData, gradientId) { return createGradient(gradientData, gradientId); } diff --git a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.test.js b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.test.js new file mode 100644 index 0000000000..0221c1d267 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.test.js @@ -0,0 +1,64 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it, vi } from 'vitest'; +import { ShapeGroupView } from './ShapeGroupView.js'; + +function createView(kind) { + return new ShapeGroupView({ + node: { + attrs: { + width: 120, + height: 80, + shapes: [ + { + shapeType: 'vectorShape', + attrs: { + kind, + x: 0, + y: 0, + width: 120, + height: 80, + fillColor: null, + strokeColor: '#123456', + strokeWidth: 2, + }, + }, + ], + }, + }, + editor: { view: {} }, + getPos: vi.fn(() => 0), + decorations: [], + innerDecorations: [], + extension: {}, + htmlAttributes: {}, + }); +} + +describe('ShapeGroupView connector rendering', () => { + it.each([ + ['bentConnector2', 'M 0 0 L 118 0 L 118 78'], + ['bentConnector3', 'M 0 0 L 59 0 L 59 78 L 118 78'], + ['bentConnector4', 'M 0 0 L 59 0 L 59 39 L 118 39 L 118 78'], + ['bentConnector5', 'M 0 0 L 29.5 0 L 29.5 39 L 88.5 39 L 88.5 78 L 118 78'], + ])('renders %s with non-degenerate path data', (kind, expectedPath) => { + const view = createView(kind); + const path = view.dom.querySelector('path'); + + expect(path?.getAttribute('d')).toBe(expectedPath); + expect(path?.getAttribute('vector-effect')).toBe('non-scaling-stroke'); + }); + + it.each(['curvedConnector2', 'curvedConnector3', 'curvedConnector4', 'curvedConnector5'])( + 'renders %s without duplicate consecutive curve endpoints', + (kind) => { + const view = createView(kind); + const path = view.dom.querySelector('path'); + + expect(path?.getAttribute('d')).toBeTruthy(); + expect(path?.getAttribute('d')).not.toContain('59 39 59 39'); + expect(path?.getAttribute('vector-effect')).toBe('non-scaling-stroke'); + }, + ); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js index fd1676a887..332d1e3ce1 100644 --- a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js @@ -1,4 +1,33 @@ import { formatChapterPageNumberText, formatPageNumber } from '@superdoc/contracts'; +import { + applyNonScalingStrokeToConnector, + applyNonScalingStrokeToConnectorTarget, + createConnectorPresetSvg, + createLineEndMarker as createLineEndMarkerWithDocument, + createLineEndShape as createLineEndShapeWithDocument, + formatSvgNumber, + getConnectorPresetPath, + getConnectorStrokePadding, + isConnectorPresetShape, +} from '@superdoc/preset-geometry/connectors'; + +export { + applyNonScalingStrokeToConnector, + applyNonScalingStrokeToConnectorTarget, + createConnectorPresetSvg, + formatSvgNumber, + getConnectorPresetPath, + getConnectorStrokePadding, + isConnectorPresetShape, +}; + +export function createLineEndShape(type, strokeColor, isStart) { + return createLineEndShapeWithDocument(document, type, strokeColor, isStart); +} + +export function createLineEndMarker(defs, id, lineEnd, strokeColor, strokeWidth, isStart) { + return createLineEndMarkerWithDocument(document, defs, id, lineEnd, strokeColor, strokeWidth, isStart); +} /** * Shared utility functions for SVG shape rendering diff --git a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js index dcd4c80d16..c1b9669b98 100644 --- a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js @@ -2,9 +2,49 @@ * @vitest-environment jsdom */ import { describe, it, expect, beforeEach } from 'vitest'; -import { createTextElement, createGradient, generateTransforms } from './svg-utils.js'; +import { + createTextElement, + createGradient, + generateTransforms, + getConnectorPresetPath, + createConnectorPresetSvg, +} from './svg-utils.js'; describe('svg-utils', () => { + describe('connector preset helpers', () => { + it.each([ + ['bentConnector2', 'M 0 0 L 120 0 L 120 80'], + ['bentConnector3', 'M 0 0 L 60 0 L 60 80 L 120 80'], + ['bentConnector4', 'M 0 0 L 60 0 L 60 40 L 120 40 L 120 80'], + ['bentConnector5', 'M 0 0 L 30 0 L 30 40 L 90 40 L 90 80 L 120 80'], + ])('generates non-degenerate %s path data', (kind, expectedPath) => { + expect(getConnectorPresetPath(kind, 120, 80)).toBe(expectedPath); + }); + + it.each(['curvedConnector2', 'curvedConnector3', 'curvedConnector4', 'curvedConnector5'])( + 'generates %s path data without duplicate consecutive curve endpoints', + (kind) => { + const path = getConnectorPresetPath(kind, 120, 80); + expect(path).toBeTruthy(); + expect(path).not.toContain('60 40 60 40'); + }, + ); + + it('creates connector SVGs with stroke padding and non-scaling stroke', () => { + const svg = createConnectorPresetSvg({ + kind: 'bentConnector4', + strokeColor: '#123456', + strokeWidth: 4, + width: 120, + height: 80, + }); + + expect(svg).toContain('viewBox="-2 -2 124 84"'); + expect(svg).toContain('vector-effect="non-scaling-stroke"'); + expect(svg).toContain('stroke="#123456"'); + }); + }); + describe('createTextElement', () => { const createBasicTextContent = (text = 'Hello World') => ({ parts: [{ text, formatting: {} }], diff --git a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js index 05b76dd882..5d64821cb6 100644 --- a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js +++ b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js @@ -8,87 +8,13 @@ import { applyGradientToSVG, applyAlphaToSVG, generateTransforms, + applyNonScalingStrokeToConnector, + createConnectorPresetSvg, + createLineEndMarker, + formatSvgNumber, + isConnectorPresetShape, } from '../shared/svg-utils.js'; -const CONNECTOR_PRESET_SHAPES = new Set([ - 'bentConnector2', - 'bentConnector3', - 'bentConnector4', - 'bentConnector5', - 'curvedConnector2', - 'curvedConnector3', - 'curvedConnector4', - 'curvedConnector5', -]); -const CONNECTOR_SVG_ELEMENTS = 'path, line, polyline'; - -function isConnectorPresetShape(kind) { - return typeof kind === 'string' && CONNECTOR_PRESET_SHAPES.has(kind); -} - -function applyNonScalingStrokeToConnector(svgElement) { - svgElement.querySelectorAll(CONNECTOR_SVG_ELEMENTS).forEach((target) => { - const stroke = target.getAttribute('stroke'); - if (!stroke || stroke === 'none') return; - target.setAttribute('vector-effect', 'non-scaling-stroke'); - }); -} - -function formatSvgNumber(value) { - return Number.isFinite(value) ? Number(value.toFixed(4)).toString() : '0'; -} - -function getConnectorPresetPath(kind, width, height) { - const w = Math.max(0, width); - const h = Math.max(0, height); - const xMid = w / 2; - const yMid = h / 2; - const xQuarter = w * 0.25; - const xThreeQuarter = w * 0.75; - const yQuarter = h * 0.25; - const yThreeQuarter = h * 0.75; - const fmt = formatSvgNumber; - - switch (kind) { - case 'bentConnector2': - return `M 0 0 L ${fmt(w)} 0 L ${fmt(w)} ${fmt(h)}`; - case 'bentConnector3': - return `M 0 0 L ${fmt(xMid)} 0 L ${fmt(xMid)} ${fmt(h)} L ${fmt(w)} ${fmt(h)}`; - case 'bentConnector4': - return `M 0 0 L ${fmt(xMid)} 0 L ${fmt(xMid)} ${fmt(yMid)} L ${fmt(w)} ${fmt(yMid)} L ${fmt(w)} ${fmt(h)}`; - case 'bentConnector5': - return `M 0 0 L ${fmt(xMid)} 0 L ${fmt(xMid)} ${fmt(yMid)} L ${fmt(xMid)} ${fmt(yMid)} L ${fmt(xMid)} ${fmt(h)} L ${fmt(w)} ${fmt(h)}`; - case 'curvedConnector2': - return `M 0 0 C ${fmt(xMid)} 0 ${fmt(w)} ${fmt(yMid)} ${fmt(w)} ${fmt(h)}`; - case 'curvedConnector3': - return `M 0 0 C ${fmt(xQuarter)} 0 ${fmt(xMid)} ${fmt(yQuarter)} ${fmt(xMid)} ${fmt(yMid)} C ${fmt(xMid)} ${fmt(yThreeQuarter)} ${fmt(xThreeQuarter)} ${fmt(h)} ${fmt(w)} ${fmt(h)}`; - case 'curvedConnector4': - return `M 0 0 C ${fmt(xQuarter)} 0 ${fmt(xMid)} ${fmt(h * 0.125)} ${fmt(xMid)} ${fmt(yQuarter)} C ${fmt(xMid)} ${fmt(h * 0.375)} ${fmt(w * 0.625)} ${fmt(yMid)} ${fmt(xThreeQuarter)} ${fmt(yMid)} C ${fmt(w * 0.875)} ${fmt(yMid)} ${fmt(w)} ${fmt(yThreeQuarter)} ${fmt(w)} ${fmt(h)}`; - case 'curvedConnector5': - return `M 0 0 C ${fmt(xQuarter)} 0 ${fmt(xMid)} ${fmt(h * 0.125)} ${fmt(xMid)} ${fmt(yQuarter)} C ${fmt(xMid)} ${fmt(h * 0.375)} ${fmt(xMid)} ${fmt(yMid)} ${fmt(xMid)} ${fmt(yMid)} C ${fmt(xMid)} ${fmt(yMid)} ${fmt(xMid)} ${fmt(h * 0.625)} ${fmt(xMid)} ${fmt(yThreeQuarter)} C ${fmt(xMid)} ${fmt(h * 0.875)} ${fmt(xThreeQuarter)} ${fmt(h)} ${fmt(w)} ${fmt(h)}`; - default: - return null; - } -} - -function createConnectorPresetSvg({ kind, strokeColor, strokeWidth, width, height }) { - const pathD = getConnectorPresetPath(kind, width, height); - if (!pathD) return null; - - const stroke = strokeColor === null ? 'none' : strokeColor || '#000000'; - const resolvedStrokeWidth = strokeWidth ?? 1; - const formattedWidth = formatSvgNumber(width); - const formattedHeight = formatSvgNumber(height); - const strokePadding = stroke !== 'none' && resolvedStrokeWidth > 0 ? resolvedStrokeWidth / 2 : 0; - const viewBoxX = formatSvgNumber(-strokePadding); - const viewBoxY = formatSvgNumber(-strokePadding); - const viewBoxWidth = formatSvgNumber(width + strokePadding * 2); - const viewBoxHeight = formatSvgNumber(height + strokePadding * 2); - return ` - -`; -} - export class VectorShapeView { node; @@ -549,87 +475,17 @@ export class VectorShapeView { if (lineEnds.head) { const id = `${idBase}-head`; - this.createLineEndMarker(defs, id, lineEnds.head, strokeColor, strokeWidth, true); + createLineEndMarker(defs, id, lineEnds.head, strokeColor, strokeWidth, true); target.setAttribute('marker-start', `url(#${id})`); } if (lineEnds.tail) { const id = `${idBase}-tail`; - this.createLineEndMarker(defs, id, lineEnds.tail, strokeColor, strokeWidth, false); + createLineEndMarker(defs, id, lineEnds.tail, strokeColor, strokeWidth, false); target.setAttribute('marker-end', `url(#${id})`); } } - /** - * Creates an SVG marker element for a line end (arrowhead). - * @param {SVGDefsElement} defs - The defs element to append the marker to - * @param {string} id - Unique ID for the marker - * @param {Object} lineEnd - Line end configuration with type, width, length - * @param {string} strokeColor - Color to use for the marker fill - * @param {number} _strokeWidth - Stroke width (currently unused, reserved for future scaling) - * @param {boolean} isStart - Whether this is a start marker (head) or end marker (tail) - */ - createLineEndMarker(defs, id, lineEnd, strokeColor, _strokeWidth, isStart) { - if (defs.querySelector(`#${id}`)) return; - - const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); - marker.setAttribute('id', id); - marker.setAttribute('viewBox', '0 0 10 10'); - marker.setAttribute('orient', 'auto'); - - const sizeScale = (value) => { - if (value === 'sm') return 0.75; - if (value === 'lg') return 1.25; - return 1; - }; - const markerWidth = 8 * sizeScale(lineEnd.length); - const markerHeight = 8 * sizeScale(lineEnd.width); - marker.setAttribute('markerUnits', 'strokeWidth'); - marker.setAttribute('markerWidth', markerWidth.toString()); - marker.setAttribute('markerHeight', markerHeight.toString()); - marker.setAttribute('refX', isStart ? '0' : '10'); - marker.setAttribute('refY', '5'); - - const shape = this.createLineEndShape(lineEnd.type || 'triangle', strokeColor, isStart); - marker.appendChild(shape); - defs.appendChild(marker); - } - - /** - * Creates an SVG shape element for a line end marker. - * Supports diamond, oval, and triangle (default) shapes. - * @param {string} type - The shape type ('diamond', 'oval', or 'triangle') - * @param {string} strokeColor - Color to fill the shape with - * @param {boolean} isStart - Whether this is a start marker (affects triangle orientation) - * @returns {SVGElement} The created SVG shape element - */ - createLineEndShape(type, strokeColor, isStart) { - const normalized = type.toLowerCase(); - if (normalized === 'diamond') { - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - path.setAttribute('d', 'M 0 5 L 5 0 L 10 5 L 5 10 Z'); - path.setAttribute('fill', strokeColor); - path.setAttribute('stroke', 'none'); - return path; - } - if (normalized === 'oval') { - const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - circle.setAttribute('cx', '5'); - circle.setAttribute('cy', '5'); - circle.setAttribute('r', '5'); - circle.setAttribute('fill', strokeColor); - circle.setAttribute('stroke', 'none'); - return circle; - } - - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - const d = isStart ? 'M 10 0 L 0 5 L 10 10 Z' : 'M 0 0 L 10 5 L 0 10 Z'; - path.setAttribute('d', d); - path.setAttribute('fill', strokeColor); - path.setAttribute('stroke', 'none'); - return path; - } - createGradient(gradientData, gradientId) { return createGradient(gradientData, gradientId); } diff --git a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.test.js b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.test.js index 9c3e2cfce5..36c788f331 100644 --- a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.test.js +++ b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.test.js @@ -12,13 +12,17 @@ vi.mock('@converter/helpers.js', () => ({ inchesToPixels: vi.fn((inches) => inches * 96), })); -vi.mock('../shared/svg-utils.js', () => ({ - createGradient: vi.fn(), - createTextElement: vi.fn(), - applyGradientToSVG: vi.fn(), - applyAlphaToSVG: vi.fn(), - generateTransforms: vi.fn(() => []), -})); +vi.mock('../shared/svg-utils.js', async () => { + const actual = await vi.importActual('../shared/svg-utils.js'); + return { + ...actual, + createGradient: vi.fn(), + createTextElement: vi.fn(), + applyGradientToSVG: vi.fn(), + applyAlphaToSVG: vi.fn(), + generateTransforms: vi.fn(() => []), + }; +}); describe('VectorShapeView', () => { let mockEditor; From a0c01dc2f71d5b1e54d0839248861d0371eb5895 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 15 Jun 2026 10:09:06 -0300 Subject: [PATCH 11/30] fix: update header media positioning comments --- packages/layout-engine/painters/dom/src/renderer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 7573e7d46e..36bf49fcda 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2403,7 +2403,7 @@ export class DomPainter { // Footer page-relative: fragment.y is normalized to band-local coords pageY = footerAnchorPageOriginY + fragment.y; } else if (isPageRelative) { - // Header page-relative: fragment.y is raw inner-layout absolute Y + // Header page-relative: fragment.y is already normalized to physical page Y pageY = fragment.y; } else { pageY = effectiveOffset + fragment.y + (kind === 'footer' ? footerYOffset : 0); @@ -2438,7 +2438,7 @@ export class DomPainter { // Footer page-relative: fragment.y is normalized to band-local coords fragEl.style.top = `${fragment.y + footerAnchorContainerOffsetY}px`; } else if (isPageRelative) { - // Header page-relative: convert raw inner-layout Y to container-local + // Header page-relative: convert physical page Y to container-local fragEl.style.top = `${fragment.y - effectiveOffset}px`; } else if (footerYOffset > 0) { // Non-anchored footer content: push to bottom of container From 6a775a0186a86a12011574e0a03984f6ec2b79b0 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 15 Jun 2026 10:09:24 -0300 Subject: [PATCH 12/30] fix: remove stale table indent fixture --- .../layout-engine/painters/dom/src/table/renderTableCell.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts index 4b11c34928..60e535d6e7 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -652,7 +652,6 @@ describe('renderTableCell', () => { const { cellElement } = renderTableCell({ ...createBaseDeps(), x: 40, - tableIndent: 20, cellMeasure, cell, }); From 7deab8666456083b87ea1f2a4cf1449b8c837ad0 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 15 Jun 2026 10:09:47 -0300 Subject: [PATCH 13/30] test: cover header media x-axis normalization --- .../normalize-header-footer-fragments.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts index bbe24c4f6f..d25c116536 100644 --- a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts +++ b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts @@ -61,6 +61,47 @@ describe('normalizeFragmentsForRegion (header/footer page-relative anchors)', () expect(fragment.x).toBeCloseTo((816 - imgWidth) / 2); expect(fragment.y).toBeCloseTo((PAGE_HEIGHT - imgHeight) / 2); }); + + it('normalizes right-aligned page-relative anchors against the physical page', () => { + const imgWidth = 120; + const block: FlowBlock = { + kind: 'image', + id: 'header-logo', + src: 'test.png', + anchor: { + isAnchored: true, + hRelativeFrom: 'page', + alignH: 'right', + offsetH: 12, + }, + }; + const fragment = makeAnchoredImageFragment('header-logo', 0, 40, imgWidth); + const pages = [{ number: 1, fragments: [fragment] }]; + + normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'header', fullConstraints); + + expect(fragment.x).toBe(816 - imgWidth - 12); + }); + + it('normalizes left page-relative anchors with explicit offsets', () => { + const block: FlowBlock = { + kind: 'image', + id: 'header-left-offset', + src: 'test.png', + anchor: { + isAnchored: true, + hRelativeFrom: 'page', + alignH: 'left', + offsetH: 24, + }, + }; + const fragment = makeAnchoredImageFragment('header-left-offset', 0, 40, 120); + const pages = [{ number: 1, fragments: [fragment] }]; + + normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'header', fullConstraints); + + expect(fragment.x).toBe(24); + }); }); describe('page-relative anchors in footer', () => { From 303bbafe756a6b3c2126996a1c512d8e72e4d084 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 15 Jun 2026 10:11:43 -0300 Subject: [PATCH 14/30] fix: render node view picture fills --- .../extensions/shape-group/ShapeGroupView.js | 3 ++ .../shape-group/ShapeGroupView.test.js | 21 +++++++++++- .../editors/v1/extensions/shared/svg-utils.js | 23 +++++++++++++ .../vector-shape/VectorShapeView.js | 19 +++++++++++ .../vector-shape/VectorShapeView.test.js | 33 +++++++++++++++++++ 5 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js index f23050deb1..71cff7f039 100644 --- a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js +++ b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js @@ -4,6 +4,7 @@ import { applyNonScalingStrokeToConnectorTarget, createGradient, createLineEndMarker, + createPictureFillPattern, createTextElement, getConnectorPresetPath, getConnectorStrokePadding, @@ -264,6 +265,8 @@ export class ShapeGroupView { const gradient = this.createGradient(fillColor, gradientId); defs.appendChild(gradient); fillValue = `url(#${gradientId})`; + } else if (fillColor && typeof fillColor === 'object' && fillColor.type === 'picture') { + fillValue = createPictureFillPattern(defs, fillColor) ?? 'none'; } else if (fillColor === null) { fillValue = 'none'; // Transparent } else if (typeof fillColor === 'string') { diff --git a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.test.js b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.test.js index 0221c1d267..ac51bc9f42 100644 --- a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.test.js +++ b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.test.js @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'; import { ShapeGroupView } from './ShapeGroupView.js'; -function createView(kind) { +function createView(kind, attrs = {}) { return new ShapeGroupView({ node: { attrs: { @@ -22,6 +22,7 @@ function createView(kind) { fillColor: null, strokeColor: '#123456', strokeWidth: 2, + ...attrs, }, }, ], @@ -62,3 +63,21 @@ describe('ShapeGroupView connector rendering', () => { }, ); }); + +describe('ShapeGroupView picture fills', () => { + it('renders vector child picture fills as SVG patterns', () => { + const view = createView('rect', { + fillColor: { + type: 'picture', + src: 'data:image/png;base64,group-picture', + }, + }); + + const filledShape = view.dom.querySelector('[fill^="url(#picture-fill-"]'); + const pattern = view.dom.querySelector('pattern'); + const image = view.dom.querySelector('pattern image'); + expect(filledShape?.getAttribute('fill')).toMatch(/^url\(#picture-fill-/); + expect(pattern?.getAttribute('patternUnits')).toBe('objectBoundingBox'); + expect(image?.getAttribute('href')).toBe('data:image/png;base64,group-picture'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js index 332d1e3ce1..aecc411676 100644 --- a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js @@ -29,6 +29,29 @@ export function createLineEndMarker(defs, id, lineEnd, strokeColor, strokeWidth, return createLineEndMarkerWithDocument(document, defs, id, lineEnd, strokeColor, strokeWidth, isStart); } +export function createPictureFillPattern(defs, pictureFill, prefix = 'picture-fill') { + if (!pictureFill?.src) return null; + + const patternId = `${prefix}-${Math.random().toString(36).slice(2, 11)}-${Date.now()}`; + const pattern = document.createElementNS('http://www.w3.org/2000/svg', 'pattern'); + pattern.setAttribute('id', patternId); + pattern.setAttribute('patternUnits', 'objectBoundingBox'); + pattern.setAttribute('width', '1'); + pattern.setAttribute('height', '1'); + + const image = document.createElementNS('http://www.w3.org/2000/svg', 'image'); + image.setAttribute('href', pictureFill.src); + image.setAttribute('x', '0'); + image.setAttribute('y', '0'); + image.setAttribute('width', '1'); + image.setAttribute('height', '1'); + image.setAttribute('preserveAspectRatio', 'none'); + pattern.appendChild(image); + defs.appendChild(pattern); + + return `url(#${patternId})`; +} + /** * Shared utility functions for SVG shape rendering * Used by VectorShapeView and ShapeGroupView diff --git a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js index 5d64821cb6..5160fcbad3 100644 --- a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js +++ b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js @@ -11,6 +11,7 @@ import { applyNonScalingStrokeToConnector, createConnectorPresetSvg, createLineEndMarker, + createPictureFillPattern, formatSvgNumber, isConnectorPresetShape, } from '../shared/svg-utils.js'; @@ -344,6 +345,8 @@ export class VectorShapeView { } else if (fillColor.type === 'solidWithAlpha') { fill = fillColor.color; fillOpacity = fillColor.alpha; + } else if (fillColor.type === 'picture') { + fill = createPictureFillPattern(defs, fillColor) ?? 'none'; } } else { fill = fillColor; @@ -416,6 +419,20 @@ export class VectorShapeView { if (isConnectorPresetShape(kind)) { applyNonScalingStrokeToConnector(tempSvg); } + if (fillColor?.type === 'picture') { + const tempDefs = + tempSvg.querySelector('defs') || + tempSvg.insertBefore( + document.createElementNS('http://www.w3.org/2000/svg', 'defs'), + tempSvg.firstChild, + ); + const pictureFill = createPictureFillPattern(tempDefs, fillColor); + if (pictureFill) { + tempSvg.querySelectorAll('[fill]:not([fill="none"])').forEach((el) => { + el.setAttribute('fill', pictureFill); + }); + } + } // Preserve the preset viewBox and scale via width/height tempSvg.setAttribute('width', width.toString()); tempSvg.setAttribute('height', height.toString()); @@ -503,6 +520,8 @@ export class VectorShapeView { fill = '#cccccc'; // Placeholder for gradients } else if (fillColor.type === 'solidWithAlpha') { fill = fillColor.color; // Use the actual color, alpha will be applied separately + } else if (fillColor.type === 'picture') { + fill = '#000000'; // Replaced by a pattern after parsing } } diff --git a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.test.js b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.test.js index 36c788f331..7222b1ab1a 100644 --- a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.test.js +++ b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.test.js @@ -343,6 +343,39 @@ describe('VectorShapeView', () => { expect(ellipse.getAttribute('ry')).toBe('50'); }); + it('renders picture fills as SVG patterns for basic shapes', () => { + const pictureNode = { + attrs: { + kind: 'rect', + width: 120, + height: 80, + fillColor: { + type: 'picture', + src: 'data:image/png;base64,abc123', + }, + strokeColor: '#000000', + strokeWidth: 1, + }, + }; + + const view = new VectorShapeView({ + node: pictureNode, + editor: mockEditor, + getPos: mockGetPos, + decorations: [], + innerDecorations: [], + extension: {}, + htmlAttributes: {}, + }); + + const rect = view.dom.querySelector('rect'); + const pattern = view.dom.querySelector('pattern'); + const image = view.dom.querySelector('pattern image'); + expect(rect.getAttribute('fill')).toMatch(/^url\(#picture-fill-/); + expect(pattern.getAttribute('patternUnits')).toBe('objectBoundingBox'); + expect(image.getAttribute('href')).toBe('data:image/png;base64,abc123'); + }); + it('uses preset geometry for complex shapes with preserveAspectRatio="none"', () => { const complexNode = { attrs: { From 10f3b5970c69151a212a051b5cf2579189f7be62 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 15 Jun 2026 10:33:45 -0300 Subject: [PATCH 15/30] fix: normalize column-relative header/footer media X via shared resolver Replace the local computePhysicalAnchorX helper with the shared resolveAnchoredGraphicX from @superdoc/contracts, and apply X normalization to all page-relative fragments rather than only those with a page-relative horizontal anchor. Column-relative drawings in headers and footers now resolve their X against the physical page margins, fixing media placed at the wrong horizontal position. --- .../normalize-header-footer-fragments.test.ts | 24 ++++++++++++++++ .../src/normalize-header-footer-fragments.ts | 28 +++++++++---------- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts index d25c116536..6bdc25e240 100644 --- a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts +++ b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts @@ -102,6 +102,30 @@ describe('normalizeFragmentsForRegion (header/footer page-relative anchors)', () expect(fragment.x).toBe(24); }); + + it('normalizes column-relative X using the physical page margin when Y is page-relative', () => { + const imgWidth = 830; + const block: FlowBlock = { + kind: 'image', + id: 'header-column-background', + src: 'test.png', + anchor: { + isAnchored: true, + hRelativeFrom: 'column', + offsetH: -76, + vRelativeFrom: 'page', + alignV: 'top', + offsetV: 8, + }, + }; + const fragment = makeAnchoredImageFragment('header-column-background', 0, 40, imgWidth); + const pages = [{ number: 1, fragments: [fragment] }]; + + normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'header', fullConstraints); + + expect(fragment.x).toBe(-4); + expect(fragment.y).toBe(8); + }); }); describe('page-relative anchors in footer', () => { diff --git a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts index 0245be1f57..700d2b43e8 100644 --- a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts +++ b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts @@ -7,6 +7,7 @@ import type { ImageMeasure, DrawingMeasure, } from '@superdoc/contracts'; +import { resolveAnchoredGraphicX } from '@superdoc/contracts'; /** * Subset of HeaderFooterConstraints needed for fragment normalization. * Defined locally to avoid circular imports with index.ts. @@ -24,19 +25,6 @@ export type RegionConstraints = { }; }; -function computePhysicalAnchorX(block: ImageBlock | DrawingBlock, fragmentWidth: number, pageWidth: number): number { - const alignH = block.anchor?.alignH ?? 'left'; - const offsetH = block.anchor?.offsetH ?? 0; - - if (alignH === 'right') { - return pageWidth - fragmentWidth - offsetH; - } - if (alignH === 'center') { - return (pageWidth - fragmentWidth) / 2 + offsetH; - } - return offsetH; -} - /** * Compute the physical-page Y coordinate for a page-relative anchored drawing, * using the real page geometry from constraints. @@ -133,9 +121,19 @@ export function normalizeFragmentsForRegion( const block = blockById.get(fragment.blockId); if (!block || !isPageRelativeBlock(block)) continue; - if (block.anchor?.hRelativeFrom === 'page' && pageWidth != null) { + if (pageWidth != null) { const fragmentWidth = (fragment as { width?: number }).width ?? 0; - fragment.x = computePhysicalAnchorX(block, fragmentWidth, pageWidth); + const marginLeft = Math.max(0, constraints.margins.left ?? 0); + const marginRight = Math.max(0, constraints.margins.right ?? 0); + const contentWidth = Math.max(1, pageWidth - (marginLeft + marginRight)); + fragment.x = resolveAnchoredGraphicX( + block.anchor ?? {}, + 0, + { width: contentWidth, gap: 0, count: 1 }, + fragmentWidth, + { left: marginLeft, right: marginRight }, + pageWidth, + ); } if (block.anchor?.vRelativeFrom === 'page') { From 235795c502fd93c76105182ebd58cbe4f89ebcfd Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 15 Jun 2026 11:14:18 -0300 Subject: [PATCH 16/30] feat: render horizontal bar charts with data labels and axis controls Add support for horizontal (bar-direction) bar charts in the DOM chart renderer, including category labels, in-bar percentage data labels, gap width, value-axis ticks/gridlines, and axis deletion. - Parse c:gapWidth, c:dLbls (showVal/numFmt/dLblPos), axis c:delete, and c:majorGridlines from chart XML, merging series-level data labels over chart-level ones per field - Extend ChartModel/ChartSeriesData/ChartAxisConfig contracts with gapWidth, dataLabels, deleted, and majorGridlines - Add a dedicated horizontal bar layout and renderers, reversing category order for top-down display and formatting percentage labels - Account for the new elements in the SVG element-budget estimate --- packages/layout-engine/contracts/src/index.ts | 13 + .../painters/dom/src/chart-renderer.test.ts | 41 +++ .../painters/dom/src/chart-renderer.ts | 238 +++++++++++++++++- .../v3/handlers/wp/helpers/chart-helpers.js | 64 ++++- .../handlers/wp/helpers/chart-helpers.test.js | 73 ++++++ 5 files changed, 420 insertions(+), 9 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 5312c557d3..e1b9263447 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1373,12 +1373,23 @@ export type ChartSeriesData = { xValues?: number[]; /** Optional bubble radius/size values for bubble charts. */ bubbleSizes?: number[]; + /** Optional data-label settings from c:dLbls. */ + dataLabels?: ChartDataLabelsConfig; +}; + +/** Data-label configuration extracted from c:dLbls. */ +export type ChartDataLabelsConfig = { + showValue?: boolean; + numberFormat?: string; + position?: string; }; /** Axis configuration extracted from c:catAx / c:valAx. */ export type ChartAxisConfig = { title?: string; orientation?: 'minMax' | 'maxMin'; + deleted?: boolean; + majorGridlines?: boolean; }; /** Normalized chart data model parsed from OOXML chart XML. */ @@ -1389,6 +1400,8 @@ export type ChartModel = { subType?: string; /** Bar direction — 'col' for vertical columns, 'bar' for horizontal bars. */ barDirection?: 'col' | 'bar'; + /** Gap width between bar groups as a percentage of bar width. */ + gapWidth?: number; /** Data series in the chart. */ series: ChartSeriesData[]; /** Category axis config. */ diff --git a/packages/layout-engine/painters/dom/src/chart-renderer.test.ts b/packages/layout-engine/painters/dom/src/chart-renderer.test.ts index d9977c0952..bd65b58733 100644 --- a/packages/layout-engine/painters/dom/src/chart-renderer.test.ts +++ b/packages/layout-engine/painters/dom/src/chart-renderer.test.ts @@ -138,6 +138,47 @@ describe('createChartElement', () => { expect(rects.length).toBeGreaterThanOrEqual(3); }); + it('renders horizontal bar charts with category labels and in-bar percentage data labels', () => { + const el = createChartElement( + doc, + makeBarChart({ + barDirection: 'bar', + gapWidth: 78, + legendPosition: undefined, + valueAxis: { deleted: true }, + series: [ + { + name: 'Series 1', + categories: ['Skill #5', 'Skill #4', 'Skill #3'], + values: [0.5, 1, 0.25], + dataLabels: { showValue: true, numberFormat: '0%', position: 'ctr' }, + }, + ], + }), + { width: 394, height: 132, rotation: 0, flipH: false, flipV: false }, + ); + const svg = el.querySelector('svg')!; + const rects = Array.from(svg.querySelectorAll('rect')); + const textNodes = Array.from(svg.querySelectorAll('text')); + const texts = textNodes.map((text) => text.textContent); + const categoryLabels = textNodes.filter((text) => text.textContent?.startsWith('Skill #')); + const orderedCategoryLabels = [...categoryLabels] + .sort((a, b) => Number(a.getAttribute('y')) - Number(b.getAttribute('y'))) + .map((text) => text.textContent); + const firstBarHeight = Number(rects[0].getAttribute('height')); + const firstBarBottom = Number(rects[0].getAttribute('y')) + firstBarHeight; + const nextBarY = Number(rects[1].getAttribute('y')); + + expect(rects).toHaveLength(3); + expect(Number(rects[0].getAttribute('width'))).toBeGreaterThan(Number(rects[0].getAttribute('height'))); + expect(firstBarHeight).toBeGreaterThan(20); + expect(nextBarY - firstBarBottom).toBeGreaterThan(8); + expect(orderedCategoryLabels).toEqual(['Skill #3', 'Skill #4', 'Skill #5']); + expect(texts).toEqual(expect.arrayContaining(['Skill #5', 'Skill #4', 'Skill #3', '50%', '100%', '25%'])); + expect(texts).not.toContain('0.2'); + expect(svg.querySelectorAll('line')).toHaveLength(0); + }); + it('renders a pie chart as SVG paths', () => { const el = createChartElement(doc, makePieChart(), defaultGeometry); const svg = el.querySelector('svg'); diff --git a/packages/layout-engine/painters/dom/src/chart-renderer.ts b/packages/layout-engine/painters/dom/src/chart-renderer.ts index f8220bf8a3..c7284f0e7b 100644 --- a/packages/layout-engine/painters/dom/src/chart-renderer.ts +++ b/packages/layout-engine/painters/dom/src/chart-renderer.ts @@ -30,6 +30,7 @@ const AXIS_COLOR = '#595959'; const GRID_COLOR = '#E0E0E0'; const LABEL_COLOR = '#333'; const TICK_LABEL_COLOR = '#666'; +const DATA_LABEL_COLOR = '#FFFFFF'; const FONT_FAMILY = 'Calibri, Arial, sans-serif'; const PLACEHOLDER_BG = '#f8f9fa'; const PLACEHOLDER_BORDER = '#dee2e6'; @@ -38,6 +39,7 @@ const PLACEHOLDER_TEXT_COLOR = '#6c757d'; const SVG_NS = 'http://www.w3.org/2000/svg'; const CHART_PADDING = { top: 30, right: 20, bottom: 50, left: 60 }; +const HORIZONTAL_CHART_PADDING = { top: 2, right: 20, bottom: 2, left: 60 }; const VALUE_TICK_COUNT = 5; // ============================================================================ @@ -146,6 +148,20 @@ type BarChartLayout = { maxValue: number; }; +type HorizontalBarChartLayout = { + plotLeft: number; + plotTop: number; + plotWidth: number; + plotHeight: number; + groupHeight: number; + barHeight: number; + barGap: number; + baselineX: number; + valueRange: number; + minValue: number; + maxValue: number; +}; + /** * Apply performance guardrails to chart data, truncating series and data points * that exceed rendering limits. Returns the truncated data and whether truncation occurred. @@ -198,6 +214,62 @@ function computeBarLayout(width: number, height: number, series: ChartSeriesData return { plotWidth, plotHeight, groupWidth, barWidth, barGap, baselineY, valueRange, minValue, maxValue }; } +function computeHorizontalBarLayout( + width: number, + height: number, + series: ChartSeriesData[], + gapWidth?: number, + hasValueAxisLabels = false, +): HorizontalBarChartLayout { + const padding = { + ...HORIZONTAL_CHART_PADDING, + bottom: hasValueAxisLabels ? 24 : HORIZONTAL_CHART_PADDING.bottom, + }; + const plotLeft = padding.left; + const plotTop = padding.top; + const plotWidth = Math.max(1, width - padding.left - padding.right); + const plotHeight = Math.max(1, height - padding.top - padding.bottom); + + const allValues = series.flatMap((s) => s.values); + const maxValue = Math.max(0, ...allValues); + const minValue = Math.min(0, ...allValues); + const valueRange = Math.max(1, maxValue - minValue); + + const categories = series[0]?.categories ?? []; + const categoryCount = Math.max(1, categories.length); + const seriesCount = series.length; + + const groupHeight = plotHeight / categoryCount; + const gapRatio = Math.max(0, Math.min(5, (gapWidth ?? 150) / 100)); + const barHeight = Math.max(1, groupHeight / (seriesCount + gapRatio)); + const barGap = Math.max(0, (groupHeight - barHeight * seriesCount) / 2); + const baselineX = plotLeft + plotWidth * ((0 - minValue) / valueRange); + + return { + plotLeft, + plotTop, + plotWidth, + plotHeight, + groupHeight, + barHeight, + barGap, + baselineX, + valueRange, + minValue, + maxValue, + }; +} + +function toHorizontalDisplaySeries(chart: ChartModel, series: ChartSeriesData[]): ChartSeriesData[] { + if (chart.categoryAxis?.orientation === 'maxMin') return series; + + return series.map((s) => ({ + ...s, + categories: [...s.categories].reverse(), + values: [...s.values].reverse(), + })); +} + function renderBars(doc: Document, svg: SVGSVGElement, series: ChartSeriesData[], layout: BarChartLayout): void { const { groupWidth, barGap, barWidth, baselineY, valueRange, plotHeight } = layout; @@ -222,6 +294,56 @@ function renderBars(doc: Document, svg: SVGSVGElement, series: ChartSeriesData[] } } +function formatDataLabel(value: number, numberFormat?: string): string { + if (numberFormat?.includes('%')) { + const decimalMatch = numberFormat.match(/0\.(0+)%/); + const decimals = decimalMatch?.[1]?.length ?? 0; + return `${(value * 100).toFixed(decimals)}%`; + } + return formatTickValue(value); +} + +function renderHorizontalBars( + doc: Document, + svg: SVGSVGElement, + series: ChartSeriesData[], + layout: HorizontalBarChartLayout, +): void { + const { plotTop, groupHeight, barGap, barHeight, baselineX, valueRange, plotWidth } = layout; + + for (let si = 0; si < series.length; si++) { + const s = series[si]!; + const color = SERIES_COLORS[si % SERIES_COLORS.length]!; + + for (let ci = 0; ci < s.values.length; ci++) { + const value = s.values[ci]!; + const barWidth = Math.abs(value / valueRange) * plotWidth; + const x = value >= 0 ? baselineX : baselineX - barWidth; + const y = plotTop + ci * groupHeight + barGap + si * barHeight; + + const rect = doc.createElementNS(SVG_NS, 'rect'); + rect.setAttribute('x', String(x)); + rect.setAttribute('y', String(y)); + rect.setAttribute('width', String(Math.max(0.5, barWidth))); + rect.setAttribute('height', String(barHeight)); + rect.setAttribute('fill', color); + svg.appendChild(rect); + + if (s.dataLabels?.showValue) { + const label = doc.createElementNS(SVG_NS, 'text'); + label.setAttribute('x', String(x + barWidth / 2)); + label.setAttribute('y', String(y + barHeight / 2 + 3)); + label.setAttribute('text-anchor', 'middle'); + label.setAttribute('font-size', String(Math.max(7, Math.min(10, barHeight * 0.75)))); + label.setAttribute('fill', DATA_LABEL_COLOR); + label.setAttribute('font-family', FONT_FAMILY); + label.textContent = formatDataLabel(value, s.dataLabels.numberFormat); + svg.appendChild(label); + } + } + } +} + function renderAxes(doc: Document, svg: SVGSVGElement, layout: BarChartLayout): void { const { plotWidth, plotHeight, baselineY } = layout; @@ -303,6 +425,68 @@ function renderValueTicks(doc: Document, svg: SVGSVGElement, layout: BarChartLay } } +function renderHorizontalCategoryLabels( + doc: Document, + svg: SVGSVGElement, + categories: string[], + layout: HorizontalBarChartLayout, + width: number, +): void { + const { plotLeft, plotTop, groupHeight } = layout; + const categoryCount = Math.max(1, categories.length); + const fontSize = Math.max(8, Math.min(12, width / categoryCount / 5)); + + for (let ci = 0; ci < categories.length; ci++) { + const label = doc.createElementNS(SVG_NS, 'text'); + label.setAttribute('x', String(plotLeft - 6)); + label.setAttribute('y', String(plotTop + ci * groupHeight + groupHeight / 2 + 4)); + label.setAttribute('text-anchor', 'end'); + label.setAttribute('font-size', String(fontSize)); + label.setAttribute('fill', LABEL_COLOR); + label.setAttribute('font-family', FONT_FAMILY); + label.textContent = categories[ci] ?? ''; + svg.appendChild(label); + } +} + +function renderHorizontalValueTicks( + doc: Document, + svg: SVGSVGElement, + layout: HorizontalBarChartLayout, + height: number, + showGridlines: boolean, +): void { + const { plotLeft, plotTop, plotHeight, plotWidth, valueRange, minValue } = layout; + const tickStep = valueRange / VALUE_TICK_COUNT; + const fontSize = Math.max(8, Math.min(11, height / 30)); + + for (let i = 0; i <= VALUE_TICK_COUNT; i++) { + const tickValue = minValue + tickStep * i; + const tickX = plotLeft + (plotWidth * (tickValue - minValue)) / valueRange; + + const label = doc.createElementNS(SVG_NS, 'text'); + label.setAttribute('x', String(tickX)); + label.setAttribute('y', String(plotTop + plotHeight + 16)); + label.setAttribute('text-anchor', 'middle'); + label.setAttribute('font-size', String(fontSize)); + label.setAttribute('fill', TICK_LABEL_COLOR); + label.setAttribute('font-family', FONT_FAMILY); + label.textContent = formatTickValue(tickValue); + svg.appendChild(label); + + if (showGridlines && i > 0 && i < VALUE_TICK_COUNT) { + const gridLine = doc.createElementNS(SVG_NS, 'line'); + gridLine.setAttribute('x1', String(tickX)); + gridLine.setAttribute('y1', String(plotTop)); + gridLine.setAttribute('x2', String(tickX)); + gridLine.setAttribute('y2', String(plotTop + plotHeight)); + gridLine.setAttribute('stroke', GRID_COLOR); + gridLine.setAttribute('stroke-width', '0.5'); + svg.appendChild(gridLine); + } + } +} + function renderLegend(doc: Document, svg: SVGSVGElement, series: ChartSeriesData[], height: number): void { const legendY = height - 12; let legendX = CHART_PADDING.left; @@ -1310,13 +1494,29 @@ function renderDoughnutChart( * Estimate the number of SVG elements a bar chart will produce. * Used to check the element budget before rendering. */ -function estimateSvgElements(series: ChartSeriesData[], categories: string[], hasLegend: boolean): number { +function estimateSvgElements( + series: ChartSeriesData[], + categories: string[], + hasLegend: boolean, + chart: ChartModel, +): number { const bars = series.reduce((sum, s) => sum + s.values.length, 0); - const axes = 2; - const categoryLabels = categories.length; - const valueTicks = (VALUE_TICK_COUNT + 1) * 2; // labels + grid lines + const isHorizontalBar = chart.chartType === 'barChart' && chart.barDirection === 'bar'; + const categoryLabels = chart.categoryAxis?.deleted === true ? 0 : categories.length; + const dataLabels = series.reduce((sum, s) => (s.dataLabels?.showValue ? sum + s.values.length : sum), 0); const legend = hasLegend ? series.length * 2 : 0; // swatch + label per series - return bars + axes + categoryLabels + valueTicks + legend; + + if (isHorizontalBar) { + const valueAxisDeleted = chart.valueAxis?.deleted === true; + const valueLabels = valueAxisDeleted ? 0 : VALUE_TICK_COUNT + 1; + const gridLines = !valueAxisDeleted && chart.valueAxis?.majorGridlines === true ? VALUE_TICK_COUNT - 1 : 0; + return bars + categoryLabels + dataLabels + valueLabels + gridLines + legend; + } + + const axes = 2; + const valueTicks = VALUE_TICK_COUNT + 1; + const gridLines = VALUE_TICK_COUNT - 1; + return bars + axes + categoryLabels + valueTicks + gridLines + dataLabels + legend; } function renderBarChart( @@ -1332,7 +1532,7 @@ function renderBarChart( const hasLegend = chart.legendPosition !== undefined; // Enforce SVG element budget (§11): fall back to simplified rendering - const estimated = estimateSvgElements(series, categories, hasLegend); + const estimated = estimateSvgElements(series, categories, hasLegend, chart); if (estimated > SVG_ELEMENT_BUDGET) { return createChartPlaceholder(doc, container, `Chart too complex for inline rendering (${estimated} elements)`); } @@ -1343,6 +1543,32 @@ function renderBarChart( svg.setAttribute('height', '100%'); svg.style.display = 'block'; + if (chart.barDirection === 'bar') { + const valueAxisDeleted = chart.valueAxis?.deleted === true; + const horizontalSeries = toHorizontalDisplaySeries(chart, series); + const horizontalCategories = horizontalSeries[0]?.categories ?? []; + const layout = computeHorizontalBarLayout(width, height, horizontalSeries, chart.gapWidth, !valueAxisDeleted); + + renderHorizontalBars(doc, svg, horizontalSeries, layout); + if (chart.categoryAxis?.deleted !== true) { + renderHorizontalCategoryLabels(doc, svg, horizontalCategories, layout, width); + } + if (!valueAxisDeleted) { + renderHorizontalValueTicks(doc, svg, layout, height, chart.valueAxis?.majorGridlines === true); + } + + if (hasLegend) { + renderLegend(doc, svg, series, height); + } + + if (truncated) { + renderTruncationIndicator(doc, svg, width); + } + + container.appendChild(svg); + return container; + } + const layout = computeBarLayout(width, height, series); renderBars(doc, svg, series, layout); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/chart-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/chart-helpers.js index 042322f893..32b9ce19ae 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/chart-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/chart-helpers.js @@ -69,6 +69,12 @@ const findChildren = (node, name) => node?.elements?.filter((el) => el.name === */ const getAttr = (node, attr) => node?.attributes?.[attr]; +const getBooleanVal = (node, defaultValue = true) => { + const val = getAttr(node, 'val'); + if (val == null) return defaultValue; + return val === '1' || val === 'true' || val === 'on'; +}; + // ============================================================================ // Chart part resolution // ============================================================================ @@ -160,6 +166,7 @@ export function parseChartXml(chartXml) { const subType = extractGrouping(chartTypeEl); const barDirection = extractBarDirection(chartTypeEl); + const gapWidth = extractNumericChildVal(chartTypeEl, 'c:gapWidth'); const series = parseSeries(chartTypeEl, chartType); const categoryAxis = parseAxis(plotArea, 'c:catAx'); const valueAxis = parseAxis(plotArea, 'c:valAx'); @@ -170,6 +177,7 @@ export function parseChartXml(chartXml) { chartType, ...(subType && { subType }), ...(barDirection && { barDirection }), + ...(gapWidth != null && { gapWidth }), series, ...(categoryAxis && { categoryAxis }), ...(valueAxis && { valueAxis }), @@ -228,13 +236,23 @@ function extractBarDirection(chartTypeEl) { return val === 'col' || val === 'bar' ? val : undefined; } +function extractNumericChildVal(parentEl, childName) { + const child = findChild(parentEl, childName); + const rawValue = getAttr(child, 'val'); + if (rawValue == null) return undefined; + + const value = Number(rawValue); + return Number.isFinite(value) ? value : undefined; +} + /** * Parse all series (c:ser) from a chart type element. * @param {Object} chartTypeEl * @returns {import('@superdoc/contracts').ChartSeriesData[]} */ function parseSeries(chartTypeEl, chartType) { - return findChildren(chartTypeEl, 'c:ser').map((seriesEl) => parseOneSeries(seriesEl, chartType)); + const chartDataLabels = parseDataLabels(chartTypeEl); + return findChildren(chartTypeEl, 'c:ser').map((seriesEl) => parseOneSeries(seriesEl, chartType, chartDataLabels)); } /** @@ -243,13 +261,14 @@ function parseSeries(chartTypeEl, chartType) { * @param {string} chartType * @returns {import('@superdoc/contracts').ChartSeriesData} */ -function parseOneSeries(serEl, chartType) { +function parseOneSeries(serEl, chartType, chartDataLabels) { const name = extractSeriesName(serEl); const categories = extractCachedStrings(findChild(serEl, 'c:cat')); const values = extractCachedNumbers(findChild(serEl, 'c:val')); const xValues = extractCachedNumbers(findChild(serEl, 'c:xVal')); const yValues = extractCachedNumbers(findChild(serEl, 'c:yVal')); const bubbleSizes = extractCachedNumbers(findChild(serEl, 'c:bubbleSize')); + const dataLabels = mergeDataLabels(chartDataLabels, parseDataLabels(serEl)); if (chartType === 'scatterChart' || chartType === 'bubbleChart') { const parsedCategoryValues = categories.map((value) => Number(value)); @@ -268,12 +287,47 @@ function parseOneSeries(serEl, chartType) { values: seriesYValues, ...(seriesXValues.length ? { xValues: seriesXValues } : {}), ...(chartType === 'bubbleChart' && bubbleSizes.length ? { bubbleSizes } : {}), + ...(dataLabels ? { dataLabels } : {}), }; return seriesData; } - return { name, categories, values }; + return { name, categories, values, ...(dataLabels ? { dataLabels } : {}) }; +} + +function mergeDataLabels(chartDataLabels, seriesDataLabels) { + if (seriesDataLabels === undefined) return chartDataLabels ?? undefined; + if (seriesDataLabels === null) return undefined; + return { ...(chartDataLabels ?? {}), ...seriesDataLabels }; +} + +/** + * Parse data label settings from c:dLbls. + * @param {Object} parentEl + * @returns {import('@superdoc/contracts').ChartDataLabelsConfig|null|undefined} + */ +function parseDataLabels(parentEl) { + const dLbls = findChild(parentEl, 'c:dLbls'); + if (!dLbls) return undefined; + + const deleteEl = findChild(dLbls, 'c:delete'); + if (deleteEl && getBooleanVal(deleteEl)) return null; + + const showVal = findChild(dLbls, 'c:showVal'); + const numFmt = findChild(dLbls, 'c:numFmt'); + const dLblPos = findChild(dLbls, 'c:dLblPos'); + + const config = {}; + if (showVal) config.showValue = getBooleanVal(showVal); + + const numberFormat = getAttr(numFmt, 'formatCode'); + if (numberFormat) config.numberFormat = numberFormat; + + const position = getAttr(dLblPos, 'val'); + if (position) config.position = position; + + return Object.keys(config).length > 0 ? config : undefined; } /** @@ -361,10 +415,14 @@ function parseAxis(plotArea, axisName) { const title = extractAxisTitle(titleEl); const scaling = findChild(axis, 'c:scaling'); const orientation = getAttr(findChild(scaling, 'c:orientation'), 'val'); + const deleteEl = findChild(axis, 'c:delete'); + const hasMajorGridlines = Boolean(findChild(axis, 'c:majorGridlines')); const config = {}; if (title) config.title = title; if (orientation === 'minMax' || orientation === 'maxMin') config.orientation = orientation; + if (deleteEl && getBooleanVal(deleteEl) === true) config.deleted = true; + if (hasMajorGridlines) config.majorGridlines = true; return Object.keys(config).length > 0 ? config : undefined; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/chart-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/chart-helpers.test.js index c0c0935183..6be5290be9 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/chart-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/chart-helpers.test.js @@ -302,6 +302,79 @@ describe('chart-helpers', () => { expect(result.categoryAxis).toEqual({ orientation: 'minMax' }); }); + it('parses horizontal bar chart axis deletion and series value labels', () => { + const xml = makeBarChartXml(); + const chart = xml.elements.find((el) => el.name === 'c:chart'); + const plotArea = chart.elements.find((el) => el.name === 'c:plotArea'); + const barChart = plotArea.elements[0]; + const series = barChart.elements.find((el) => el.name === 'c:ser'); + const valueAxis = plotArea.elements.find((el) => el.name === 'c:valAx'); + + barChart.elements.unshift({ name: 'c:barDir', attributes: { val: 'bar' } }); + barChart.elements.push({ name: 'c:gapWidth', attributes: { val: '78' } }); + series.elements.push({ + name: 'c:dLbls', + elements: [ + { name: 'c:numFmt', attributes: { formatCode: '0%', sourceLinked: '0' } }, + { name: 'c:dLblPos', attributes: { val: 'ctr' } }, + { name: 'c:showVal', attributes: { val: '1' } }, + ], + }); + valueAxis.elements.push({ name: 'c:delete', attributes: { val: '1' } }); + + const result = parseChartXml(xml); + + expect(result.barDirection).toBe('bar'); + expect(result.gapWidth).toBe(78); + expect(result.valueAxis).toEqual({ deleted: true }); + expect(result.series[0].dataLabels).toEqual({ showValue: true, numberFormat: '0%', position: 'ctr' }); + }); + + it('merges series data labels over chart-level data labels per field', () => { + const xml = makeBarChartXml(); + const chart = xml.elements.find((el) => el.name === 'c:chart'); + const plotArea = chart.elements.find((el) => el.name === 'c:plotArea'); + const barChart = plotArea.elements[0]; + const series = barChart.elements.find((el) => el.name === 'c:ser'); + + barChart.elements.push({ + name: 'c:dLbls', + elements: [ + { name: 'c:numFmt', attributes: { formatCode: '0%', sourceLinked: '0' } }, + { name: 'c:showVal', attributes: { val: '1' } }, + ], + }); + series.elements.push({ + name: 'c:dLbls', + elements: [{ name: 'c:dLblPos', attributes: { val: 'ctr' } }], + }); + + const result = parseChartXml(xml); + + expect(result.series[0].dataLabels).toEqual({ showValue: true, numberFormat: '0%', position: 'ctr' }); + }); + + it('lets series data labels delete inherited chart-level data labels', () => { + const xml = makeBarChartXml(); + const chart = xml.elements.find((el) => el.name === 'c:chart'); + const plotArea = chart.elements.find((el) => el.name === 'c:plotArea'); + const barChart = plotArea.elements[0]; + const series = barChart.elements.find((el) => el.name === 'c:ser'); + + barChart.elements.push({ + name: 'c:dLbls', + elements: [{ name: 'c:showVal', attributes: { val: '1' } }], + }); + series.elements.push({ + name: 'c:dLbls', + elements: [{ name: 'c:delete', attributes: { val: '1' } }], + }); + + const result = parseChartXml(xml); + + expect(result.series[0].dataLabels).toBeUndefined(); + }); + it('returns null for missing chart element', () => { expect(parseChartXml({ name: 'c:chartSpace', elements: [] })).toBeNull(); }); From b890e717111f327834e9812c074166e764486953 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 15 Jun 2026 11:20:42 -0300 Subject: [PATCH 17/30] fix: invalidate chart cache on data changes --- .../src/versionSignature.test.ts | 33 +++++++++++++++++++ .../layout-resolved/src/versionSignature.ts | 6 ++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts index ac7af8dcb1..f334bcc971 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { deriveBlockVersion, sourceAnchorSignature } from './versionSignature.js'; import type { FlowBlock, + ChartDrawing, ImageBlock, ImageRun, ParagraphBlock, @@ -259,6 +260,38 @@ describe('deriveBlockVersion - vector shape effects', () => { }); }); +describe('deriveBlockVersion - chart drawings', () => { + const makeChartDrawing = (values: number[]): ChartDrawing => ({ + kind: 'drawing', + id: 'chart-1', + drawingKind: 'chart', + geometry: { width: 320, height: 180, rotation: 0, flipH: false, flipV: false }, + chartRelId: 'rIdChart1', + chartData: { + chartType: 'barChart', + barDirection: 'col', + gapWidth: 150, + series: [ + { + name: 'Series 1', + categories: ['Q1', 'Q2', 'Q3'], + values, + dataLabels: { showValue: true, numberFormat: '0%' }, + }, + ], + valueAxis: { deleted: false, majorGridlines: true }, + legendPosition: 'b', + }, + }); + + it('changes when rendered chart data changes without changing series count', () => { + const first = deriveBlockVersion(makeChartDrawing([0.25, 0.5, 0.75])); + const second = deriveBlockVersion(makeChartDrawing([0.25, 0.5, 1])); + + expect(second).not.toBe(first); + }); +}); + describe('deriveBlockVersion - table image content', () => { const makeTableWithImage = (image: ImageBlock): TableBlock => ({ kind: 'table', diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index 30bda18594..3af8519a43 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -516,10 +516,12 @@ export const deriveBlockVersion = (block: FlowBlock): string => { if (block.drawingKind === 'chart') { return [ 'drawing:chart', - block.chartData?.chartType ?? '', - block.chartData?.series?.length ?? 0, + valueVersion(block.chartData), block.geometry.width, block.geometry.height, + block.geometry.rotation ?? 0, + block.geometry.flipH ? 1 : 0, + block.geometry.flipV ? 1 : 0, block.chartRelId ?? '', ].join('|'); } From f0120341049a50391184a2783219edd34eb7309b Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 15 Jun 2026 11:22:56 -0300 Subject: [PATCH 18/30] fix: size node view picture fills correctly --- .../editors/v1/extensions/shared/svg-utils.js | 1 + .../v1/extensions/shared/svg-utils.test.js | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js index aecc411676..ac6c37b3cf 100644 --- a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js @@ -36,6 +36,7 @@ export function createPictureFillPattern(defs, pictureFill, prefix = 'picture-fi const pattern = document.createElementNS('http://www.w3.org/2000/svg', 'pattern'); pattern.setAttribute('id', patternId); pattern.setAttribute('patternUnits', 'objectBoundingBox'); + pattern.setAttribute('patternContentUnits', 'objectBoundingBox'); pattern.setAttribute('width', '1'); pattern.setAttribute('height', '1'); diff --git a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js index c1b9669b98..86c57d0104 100644 --- a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js @@ -5,12 +5,32 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { createTextElement, createGradient, + createPictureFillPattern, generateTransforms, getConnectorPresetPath, createConnectorPresetSvg, } from './svg-utils.js'; describe('svg-utils', () => { + describe('createPictureFillPattern', () => { + it('sizes picture-fill image content in object bounding box coordinates', () => { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + svg.appendChild(defs); + + const fill = createPictureFillPattern(defs, { src: 'data:image/png;base64,AAA' }, 'test-fill'); + const patternId = fill.match(/^url\(#(.+)\)$/)?.[1]; + const pattern = defs.querySelector(`#${patternId}`); + const image = pattern?.querySelector('image'); + + expect(pattern).not.toBeNull(); + expect(pattern.getAttribute('patternUnits')).toBe('objectBoundingBox'); + expect(pattern.getAttribute('patternContentUnits')).toBe('objectBoundingBox'); + expect(image.getAttribute('width')).toBe('1'); + expect(image.getAttribute('height')).toBe('1'); + }); + }); + describe('connector preset helpers', () => { it.each([ ['bentConnector2', 'M 0 0 L 120 0 L 120 80'], From 589c899898ac04b38bf7ccf9419c90876c0d99af Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 15 Jun 2026 11:24:07 -0300 Subject: [PATCH 19/30] fix: render column chart data labels --- .../painters/dom/src/chart-renderer.test.ts | 22 +++++++++++++++++++ .../painters/dom/src/chart-renderer.ts | 12 ++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/chart-renderer.test.ts b/packages/layout-engine/painters/dom/src/chart-renderer.test.ts index bd65b58733..8b5db4974c 100644 --- a/packages/layout-engine/painters/dom/src/chart-renderer.test.ts +++ b/packages/layout-engine/painters/dom/src/chart-renderer.test.ts @@ -179,6 +179,28 @@ describe('createChartElement', () => { expect(svg.querySelectorAll('line')).toHaveLength(0); }); + it('renders data labels for vertical bar charts', () => { + const el = createChartElement( + doc, + makeBarChart({ + legendPosition: undefined, + series: [ + { + name: 'Series 1', + categories: ['A', 'B', 'C'], + values: [3, 7, 11], + dataLabels: { showValue: true }, + }, + ], + }), + defaultGeometry, + ); + + const texts = Array.from(el.querySelectorAll('svg text')).map((text) => text.textContent); + + expect(texts).toEqual(expect.arrayContaining(['3', '7', '11'])); + }); + it('renders a pie chart as SVG paths', () => { const el = createChartElement(doc, makePieChart(), defaultGeometry); const svg = el.querySelector('svg'); diff --git a/packages/layout-engine/painters/dom/src/chart-renderer.ts b/packages/layout-engine/painters/dom/src/chart-renderer.ts index c7284f0e7b..179f6fd64f 100644 --- a/packages/layout-engine/painters/dom/src/chart-renderer.ts +++ b/packages/layout-engine/painters/dom/src/chart-renderer.ts @@ -290,6 +290,18 @@ function renderBars(doc: Document, svg: SVGSVGElement, series: ChartSeriesData[] rect.setAttribute('height', String(Math.max(0.5, barHeight))); rect.setAttribute('fill', color); svg.appendChild(rect); + + if (s.dataLabels?.showValue) { + const label = doc.createElementNS(SVG_NS, 'text'); + label.setAttribute('x', String(x + barWidth / 2)); + label.setAttribute('y', String(y + barHeight / 2 + 3)); + label.setAttribute('text-anchor', 'middle'); + label.setAttribute('font-size', String(Math.max(7, Math.min(10, barWidth * 0.75)))); + label.setAttribute('fill', DATA_LABEL_COLOR); + label.setAttribute('font-family', FONT_FAMILY); + label.textContent = formatDataLabel(value, s.dataLabels.numberFormat); + svg.appendChild(label); + } } } } From e51a399f1638f54b987f0b96f608d4b03c310983 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 15 Jun 2026 11:31:01 -0300 Subject: [PATCH 20/30] fix: preserve truncated chart label settings --- .../painters/dom/src/chart-renderer.test.ts | 22 +++++++++++++++++++ .../painters/dom/src/chart-renderer.ts | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/chart-renderer.test.ts b/packages/layout-engine/painters/dom/src/chart-renderer.test.ts index 8b5db4974c..67bc114aa8 100644 --- a/packages/layout-engine/painters/dom/src/chart-renderer.test.ts +++ b/packages/layout-engine/painters/dom/src/chart-renderer.test.ts @@ -319,6 +319,28 @@ describe('performance guardrails', () => { expect(el.textContent).toContain('Data truncated'); }); + it('preserves data labels when truncating oversized series', () => { + const categories = Array.from({ length: 501 }, (_, i) => `C${i}`); + const values = Array.from({ length: 501 }, (_, i) => (i + 1) / 100); + const chart = makeBarChart({ + legendPosition: undefined, + series: [ + { + name: 'Big', + categories, + values, + dataLabels: { showValue: true, numberFormat: '0%' }, + }, + ], + }); + const el = createChartElement(doc, chart, defaultGeometry); + const texts = Array.from(el.querySelectorAll('svg text')).map((text) => text.textContent); + + expect(el.textContent).toContain('Data truncated'); + expect(texts).toEqual(expect.arrayContaining(['1%', '500%'])); + expect(texts).not.toContain('501%'); + }); + it('falls back to placeholder when estimated SVG elements exceed budget (5000)', () => { // 20 series × 500 points = 10,000 bars alone → exceeds 5,000 budget const series = Array.from({ length: 20 }, (_, i) => ({ diff --git a/packages/layout-engine/painters/dom/src/chart-renderer.ts b/packages/layout-engine/painters/dom/src/chart-renderer.ts index 179f6fd64f..9c4f20b405 100644 --- a/packages/layout-engine/painters/dom/src/chart-renderer.ts +++ b/packages/layout-engine/painters/dom/src/chart-renderer.ts @@ -181,7 +181,7 @@ function applyGuardrails(chart: ChartModel): { series: ChartSeriesData[]; trunca } truncated = true; return { - name: s.name, + ...s, categories: s.categories.slice(0, MAX_POINTS_PER_SERIES), values: s.values.slice(0, MAX_POINTS_PER_SERIES), ...(s.xValues && { xValues: s.xValues.slice(0, MAX_POINTS_PER_SERIES) }), From d7906a130fb002e8baf75c2433316075666fbc0a Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 15 Jun 2026 11:32:57 -0300 Subject: [PATCH 21/30] fix: honor bar chart data label positions --- .../painters/dom/src/chart-renderer.test.ts | 28 +++++ .../painters/dom/src/chart-renderer.ts | 115 ++++++++++++++++-- 2 files changed, 135 insertions(+), 8 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/chart-renderer.test.ts b/packages/layout-engine/painters/dom/src/chart-renderer.test.ts index 67bc114aa8..8b6e18b74b 100644 --- a/packages/layout-engine/painters/dom/src/chart-renderer.test.ts +++ b/packages/layout-engine/painters/dom/src/chart-renderer.test.ts @@ -179,6 +179,34 @@ describe('createChartElement', () => { expect(svg.querySelectorAll('line')).toHaveLength(0); }); + it('positions horizontal outEnd data labels outside the bar end', () => { + const el = createChartElement( + doc, + makeBarChart({ + barDirection: 'bar', + categoryAxis: { orientation: 'maxMin' }, + legendPosition: undefined, + valueAxis: { deleted: true }, + series: [ + { + name: 'Series 1', + categories: ['A', 'B'], + values: [0.5, 1], + dataLabels: { showValue: true, numberFormat: '0%', position: 'outEnd' }, + }, + ], + }), + { width: 300, height: 120, rotation: 0, flipH: false, flipV: false }, + ); + const svg = el.querySelector('svg')!; + const firstBar = svg.querySelector('rect')!; + const firstLabel = Array.from(svg.querySelectorAll('text')).find((text) => text.textContent === '50%')!; + const barEndX = Number(firstBar.getAttribute('x')) + Number(firstBar.getAttribute('width')); + + expect(Number(firstLabel.getAttribute('x'))).toBeGreaterThan(barEndX); + expect(firstLabel.getAttribute('text-anchor')).toBe('start'); + }); + it('renders data labels for vertical bar charts', () => { const el = createChartElement( doc, diff --git a/packages/layout-engine/painters/dom/src/chart-renderer.ts b/packages/layout-engine/painters/dom/src/chart-renderer.ts index 9c4f20b405..a7d070b6d0 100644 --- a/packages/layout-engine/painters/dom/src/chart-renderer.ts +++ b/packages/layout-engine/painters/dom/src/chart-renderer.ts @@ -41,6 +41,7 @@ const SVG_NS = 'http://www.w3.org/2000/svg'; const CHART_PADDING = { top: 30, right: 20, bottom: 50, left: 60 }; const HORIZONTAL_CHART_PADDING = { top: 2, right: 20, bottom: 2, left: 60 }; const VALUE_TICK_COUNT = 5; +const DATA_LABEL_PADDING = 4; // ============================================================================ // Public API @@ -162,6 +163,13 @@ type HorizontalBarChartLayout = { maxValue: number; }; +type DataLabelPlacement = { + x: number; + y: number; + textAnchor: 'start' | 'middle' | 'end'; + fill: string; +}; + /** * Apply performance guardrails to chart data, truncating series and data points * that exceed rendering limits. Returns the truncated data and whether truncation occurred. @@ -292,12 +300,13 @@ function renderBars(doc: Document, svg: SVGSVGElement, series: ChartSeriesData[] svg.appendChild(rect); if (s.dataLabels?.showValue) { + const placement = resolveVerticalBarDataLabelPlacement(s.dataLabels.position, value, x, y, barWidth, barHeight); const label = doc.createElementNS(SVG_NS, 'text'); - label.setAttribute('x', String(x + barWidth / 2)); - label.setAttribute('y', String(y + barHeight / 2 + 3)); - label.setAttribute('text-anchor', 'middle'); + label.setAttribute('x', String(placement.x)); + label.setAttribute('y', String(placement.y)); + label.setAttribute('text-anchor', placement.textAnchor); label.setAttribute('font-size', String(Math.max(7, Math.min(10, barWidth * 0.75)))); - label.setAttribute('fill', DATA_LABEL_COLOR); + label.setAttribute('fill', placement.fill); label.setAttribute('font-family', FONT_FAMILY); label.textContent = formatDataLabel(value, s.dataLabels.numberFormat); svg.appendChild(label); @@ -315,6 +324,88 @@ function formatDataLabel(value: number, numberFormat?: string): string { return formatTickValue(value); } +function resolveHorizontalBarDataLabelPlacement( + position: string | undefined, + value: number, + x: number, + y: number, + barWidth: number, + barHeight: number, +): DataLabelPlacement { + const centerY = y + barHeight / 2 + 3; + if (position === 'outEnd') { + return { + x: value >= 0 ? x + barWidth + DATA_LABEL_PADDING : x - DATA_LABEL_PADDING, + y: centerY, + textAnchor: value >= 0 ? 'start' : 'end', + fill: LABEL_COLOR, + }; + } + if (position === 'inEnd') { + return { + x: value >= 0 ? x + barWidth - DATA_LABEL_PADDING : x + DATA_LABEL_PADDING, + y: centerY, + textAnchor: value >= 0 ? 'end' : 'start', + fill: DATA_LABEL_COLOR, + }; + } + if (position === 'inBase') { + return { + x: value >= 0 ? x + DATA_LABEL_PADDING : x + barWidth - DATA_LABEL_PADDING, + y: centerY, + textAnchor: value >= 0 ? 'start' : 'end', + fill: DATA_LABEL_COLOR, + }; + } + return { + x: x + barWidth / 2, + y: centerY, + textAnchor: 'middle', + fill: DATA_LABEL_COLOR, + }; +} + +function resolveVerticalBarDataLabelPlacement( + position: string | undefined, + value: number, + x: number, + y: number, + barWidth: number, + barHeight: number, +): DataLabelPlacement { + const centerX = x + barWidth / 2; + if (position === 'outEnd') { + return { + x: centerX, + y: value >= 0 ? y - DATA_LABEL_PADDING : y + barHeight + 10, + textAnchor: 'middle', + fill: LABEL_COLOR, + }; + } + if (position === 'inEnd') { + return { + x: centerX, + y: value >= 0 ? y + 10 : y + barHeight - DATA_LABEL_PADDING, + textAnchor: 'middle', + fill: DATA_LABEL_COLOR, + }; + } + if (position === 'inBase') { + return { + x: centerX, + y: value >= 0 ? y + barHeight - DATA_LABEL_PADDING : y + 10, + textAnchor: 'middle', + fill: DATA_LABEL_COLOR, + }; + } + return { + x: centerX, + y: y + barHeight / 2 + 3, + textAnchor: 'middle', + fill: DATA_LABEL_COLOR, + }; +} + function renderHorizontalBars( doc: Document, svg: SVGSVGElement, @@ -342,12 +433,20 @@ function renderHorizontalBars( svg.appendChild(rect); if (s.dataLabels?.showValue) { + const placement = resolveHorizontalBarDataLabelPlacement( + s.dataLabels.position, + value, + x, + y, + barWidth, + barHeight, + ); const label = doc.createElementNS(SVG_NS, 'text'); - label.setAttribute('x', String(x + barWidth / 2)); - label.setAttribute('y', String(y + barHeight / 2 + 3)); - label.setAttribute('text-anchor', 'middle'); + label.setAttribute('x', String(placement.x)); + label.setAttribute('y', String(placement.y)); + label.setAttribute('text-anchor', placement.textAnchor); label.setAttribute('font-size', String(Math.max(7, Math.min(10, barHeight * 0.75)))); - label.setAttribute('fill', DATA_LABEL_COLOR); + label.setAttribute('fill', placement.fill); label.setAttribute('font-family', FONT_FAMILY); label.textContent = formatDataLabel(value, s.dataLabels.numberFormat); svg.appendChild(label); From 16659a40239f1a8b9948b97b20a13aae22e29c2c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 15 Jun 2026 13:22:39 -0300 Subject: [PATCH 22/30] fix: avoid cross-axis header footer x rewrite --- .../normalize-header-footer-fragments.test.ts | 51 +++++++++++++++++++ .../src/normalize-header-footer-fragments.ts | 2 +- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts index 6bdc25e240..7129871ca0 100644 --- a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts +++ b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts @@ -72,6 +72,8 @@ describe('normalizeFragmentsForRegion (header/footer page-relative anchors)', () isAnchored: true, hRelativeFrom: 'page', alignH: 'right', + vRelativeFrom: 'page', + alignV: 'top', offsetH: 12, }, }; @@ -92,6 +94,8 @@ describe('normalizeFragmentsForRegion (header/footer page-relative anchors)', () isAnchored: true, hRelativeFrom: 'page', alignH: 'left', + vRelativeFrom: 'page', + alignV: 'top', offsetH: 24, }, }; @@ -179,6 +183,53 @@ describe('normalizeFragmentsForRegion (header/footer page-relative anchors)', () expect(fragment.y).toBe((PAGE_HEIGHT - imgHeight) / 2 - FOOTER_BAND_ORIGIN); }); + it('normalizes footer X when the vertical anchor uses page coordinates', () => { + const imgWidth = 120; + const block: FlowBlock = { + kind: 'image', + id: 'footer-centered', + src: 'test.png', + anchor: { + isAnchored: true, + hRelativeFrom: 'page', + alignH: 'center', + vRelativeFrom: 'page', + alignV: 'bottom', + offsetV: 0, + }, + }; + const fragment = makeAnchoredImageFragment('footer-centered', 0, 40, imgWidth); + const pages = [{ number: 1, fragments: [fragment] }]; + + normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'footer', fullConstraints); + + expect(fragment.x).toBe((816 - imgWidth) / 2); + expect(fragment.y).toBe(PAGE_HEIGHT - 40 - FOOTER_BAND_ORIGIN); + }); + + it('does not rewrite page-relative X when the vertical anchor is not page-relative', () => { + const block: FlowBlock = { + kind: 'image', + id: 'footer-cross-axis', + src: 'test.png', + anchor: { + isAnchored: true, + hRelativeFrom: 'page', + alignH: 'center', + vRelativeFrom: 'paragraph', + offsetV: 0, + }, + }; + const fragment = makeAnchoredImageFragment('footer-cross-axis', 12, 40, 120); + fragment.x = 24; + const pages = [{ number: 1, fragments: [fragment] }]; + + normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'footer', fullConstraints); + + expect(fragment.x).toBe(24); + expect(fragment.y).toBe(12); + }); + it('applies offsetV correctly', () => { const block: FlowBlock = { kind: 'image', diff --git a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts index 700d2b43e8..5d4cbfc5e2 100644 --- a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts +++ b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts @@ -121,7 +121,7 @@ export function normalizeFragmentsForRegion( const block = blockById.get(fragment.blockId); if (!block || !isPageRelativeBlock(block)) continue; - if (pageWidth != null) { + if (pageWidth != null && block.anchor?.vRelativeFrom === 'page') { const fragmentWidth = (fragment as { width?: number }).width ?? 0; const marginLeft = Math.max(0, constraints.margins.left ?? 0); const marginRight = Math.max(0, constraints.margins.right ?? 0); From e53bd7cc199c9b9e0403ba2e6d0acc266c6a2ee8 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 15 Jun 2026 13:23:42 -0300 Subject: [PATCH 23/30] fix: hash object layout version values --- .../src/versionSignature.test.ts | 23 +++++++++++++++++++ .../layout-resolved/src/versionSignature.ts | 9 +++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts index f334bcc971..c499754cfd 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts @@ -258,6 +258,21 @@ describe('deriveBlockVersion - vector shape effects', () => { expect(deriveBlockVersion(withPictureB)).not.toBe(deriveBlockVersion(withPictureA)); }); + + it('does not embed picture fill data URIs in the version string', () => { + const version = deriveBlockVersion({ + ...makeVectorShape(), + fillColor: { + type: 'picture', + src: `data:image/png;base64,${'A'.repeat(1024)}`, + rId: 'rId1', + extension: 'png', + }, + }); + + expect(version).not.toContain('data:image/png;base64'); + expect(version.length).toBeLessThan(300); + }); }); describe('deriveBlockVersion - chart drawings', () => { @@ -290,6 +305,14 @@ describe('deriveBlockVersion - chart drawings', () => { expect(second).not.toBe(first); }); + + it('hashes chart data instead of embedding every series point', () => { + const values = Array.from({ length: 1000 }, (_, index) => index); + const version = deriveBlockVersion(makeChartDrawing(values)); + + expect(version).not.toContain('999'); + expect(version.length).toBeLessThan(200); + }); }); describe('deriveBlockVersion - table image content', () => { diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index 3af8519a43..a43af8505e 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -59,7 +59,10 @@ const getSdtMetadataVersion = (metadata: SdtMetadata | null | undefined): string const valueVersion = (value: unknown): string => { if (value == null) return ''; - if (typeof value === 'object') return JSON.stringify(value); + if (typeof value === 'object') { + const serialized = stableSerializeEvidenceValue(value); + return `h:${serialized.length}:${hashString(2166136261, serialized).toString(36)}`; + } return String(value); }; @@ -278,7 +281,7 @@ const hashNumber = (seed: number, value: number | undefined | null): number => { // sourceAnchorSignature // --------------------------------------------------------------------------- -const stableSerializeEvidenceValue = (value: unknown): string => { +function stableSerializeEvidenceValue(value: unknown): string { if (value === undefined) return ''; if (value === null) return 'null'; if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { @@ -296,7 +299,7 @@ const stableSerializeEvidenceValue = (value: unknown): string => { .join(',')}}`; } return JSON.stringify(String(value)); -}; +} /** * Stable source/evidence metadata signature for paint cache invalidation. From 32e12e690fc09f1735541ddd76fa07abd1fa67f2 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 15 Jun 2026 13:25:09 -0300 Subject: [PATCH 24/30] fix: share group connector svg padding --- .../extensions/shape-group/ShapeGroupView.js | 34 ++++++------------- .../shape-group/ShapeGroupView.test.js | 20 ++++++++--- 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js index 71cff7f039..9ba5cff223 100644 --- a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js +++ b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js @@ -2,14 +2,12 @@ import { getPresetShapeSvg } from '@superdoc/preset-geometry'; import { applyNonScalingStrokeToConnectorTarget, + createConnectorPresetSvg, createGradient, createLineEndMarker, createPictureFillPattern, createTextElement, - getConnectorPresetPath, - getConnectorStrokePadding, isConnectorPresetShape, - formatSvgNumber, } from '../shared/svg-utils.js'; import { OOXML_Z_INDEX_BASE } from '@extensions/shared/constants.js'; @@ -404,29 +402,19 @@ export class ShapeGroupView { } if (isConnectorPresetShape(shapeKind)) { - const strokePadding = getConnectorStrokePadding(strokeColor, strokeWidth); - const pathWidth = Math.max(0, width - strokePadding * 2); - const pathHeight = Math.max(0, height - strokePadding * 2); - const pathD = getConnectorPresetPath(shapeKind, pathWidth, pathHeight); - if (pathD) { - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - path.setAttribute('d', pathD); - path.setAttribute('fill', 'none'); - path.setAttribute('stroke', strokeColor === null ? 'none' : strokeColor || '#000000'); - path.setAttribute('stroke-width', (strokeColor === null ? 0 : strokeWidth).toString()); - if (strokePadding > 0) { - path.setAttribute( - 'transform', - `translate(${formatSvgNumber(strokePadding)}, ${formatSvgNumber(strokePadding)})`, - ); - } - applyNonScalingStrokeToConnectorTarget(path); - - if (lineEnds && strokeColor !== null) { + const connectorSvgMarkup = createConnectorPresetSvg({ kind: shapeKind, strokeColor, strokeWidth, width, height }); + if (connectorSvgMarkup) { + const template = document.createElement('template'); + template.innerHTML = connectorSvgMarkup.trim(); + const connectorSvg = template.content.firstElementChild; + const path = connectorSvg?.querySelector('path'); + if (path && lineEnds && strokeColor !== null) { const markerBase = `line-end-${shapeIndex}-${Date.now()}-${Math.floor(Math.random() * 1e9)}`; this.applyLineEndsToTarget(path, lineEnds, strokeColor, strokeWidth, defs, markerBase); } - g.appendChild(path); + if (connectorSvg) { + g.appendChild(connectorSvg); + } } if (attrs.textContent && attrs.textContent.parts) { diff --git a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.test.js b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.test.js index ac51bc9f42..3f0357710a 100644 --- a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.test.js +++ b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.test.js @@ -39,10 +39,10 @@ function createView(kind, attrs = {}) { describe('ShapeGroupView connector rendering', () => { it.each([ - ['bentConnector2', 'M 0 0 L 118 0 L 118 78'], - ['bentConnector3', 'M 0 0 L 59 0 L 59 78 L 118 78'], - ['bentConnector4', 'M 0 0 L 59 0 L 59 39 L 118 39 L 118 78'], - ['bentConnector5', 'M 0 0 L 29.5 0 L 29.5 39 L 88.5 39 L 88.5 78 L 118 78'], + ['bentConnector2', 'M 0 0 L 120 0 L 120 80'], + ['bentConnector3', 'M 0 0 L 60 0 L 60 80 L 120 80'], + ['bentConnector4', 'M 0 0 L 60 0 L 60 40 L 120 40 L 120 80'], + ['bentConnector5', 'M 0 0 L 30 0 L 30 40 L 90 40 L 90 80 L 120 80'], ])('renders %s with non-degenerate path data', (kind, expectedPath) => { const view = createView(kind); const path = view.dom.querySelector('path'); @@ -58,10 +58,20 @@ describe('ShapeGroupView connector rendering', () => { const path = view.dom.querySelector('path'); expect(path?.getAttribute('d')).toBeTruthy(); - expect(path?.getAttribute('d')).not.toContain('59 39 59 39'); + expect(path?.getAttribute('d')).not.toContain('60 40 60 40'); expect(path?.getAttribute('vector-effect')).toBe('non-scaling-stroke'); }, ); + + it('uses the shared viewBox padding strategy for connector strokes', () => { + const view = createView('bentConnector4', { strokeWidth: 4 }); + const connectorSvg = view.dom.querySelector('g svg'); + const path = connectorSvg?.querySelector('path'); + + expect(connectorSvg?.getAttribute('viewBox')).toBe('-2 -2 124 84'); + expect(path?.getAttribute('d')).toBe('M 0 0 L 60 0 L 60 40 L 120 40 L 120 80'); + expect(path?.hasAttribute('transform')).toBe(false); + }); }); describe('ShapeGroupView picture fills', () => { From a175bbe498b3a25913f4e744c9220d4c0fde8741 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 15 Jun 2026 13:26:34 -0300 Subject: [PATCH 25/30] fix: share converter media target normalization --- .../v3/handlers/helpers/media-target-path.js | 16 +++++++++++++++ .../helpers/media-target-path.test.js | 20 +++++++++++++++++++ .../handle-shape-image-watermark-import.js | 14 +------------ .../wp/helpers/encode-image-node-helpers.js | 14 +------------ .../wp/helpers/vector-shape-helpers.js | 9 +-------- 5 files changed, 39 insertions(+), 34 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/media-target-path.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/media-target-path.test.js diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/media-target-path.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/media-target-path.js new file mode 100644 index 0000000000..751d502110 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/media-target-path.js @@ -0,0 +1,16 @@ +/** + * Normalize a relationship target to the media key shape stored in docx media maps. + * + * Relationship targets are commonly "media/image.png" while imported media is + * keyed as "word/media/image.png". Keep existing behavior for other relative + * targets by prefixing "word/" after stripping leading package slashes. + * + * @param {string} targetPath + * @returns {string} + */ +export function normalizeTargetPath(targetPath = '') { + if (!targetPath) return targetPath; + const trimmed = targetPath.replace(/^\/+/, ''); + if (trimmed.startsWith('word/')) return trimmed; + return `word/${trimmed}`; +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/media-target-path.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/media-target-path.test.js new file mode 100644 index 0000000000..37ef8be27b --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/media-target-path.test.js @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeTargetPath } from './media-target-path.js'; + +describe('normalizeTargetPath', () => { + it('keeps empty targets unchanged', () => { + expect(normalizeTargetPath()).toBe(''); + expect(normalizeTargetPath('')).toBe(''); + }); + + it('keeps word-relative targets unchanged after trimming leading slashes', () => { + expect(normalizeTargetPath('word/media/image.png')).toBe('word/media/image.png'); + expect(normalizeTargetPath('/word/media/image.png')).toBe('word/media/image.png'); + }); + + it('prefixes media-relative and bare targets with word', () => { + expect(normalizeTargetPath('media/image.png')).toBe('word/media/image.png'); + expect(normalizeTargetPath('/media/image.png')).toBe('word/media/image.png'); + expect(normalizeTargetPath('image.png')).toBe('word/image.png'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-image-watermark-import.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-image-watermark-import.js index c662307a22..c19743025e 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-image-watermark-import.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-image-watermark-import.js @@ -1,4 +1,5 @@ import { carbonCopy } from '@core/utilities/carbonCopy.js'; +import { normalizeTargetPath } from '../../../helpers/media-target-path.js'; /** * Handles VML shape elements with v:imagedata (image watermarks). @@ -134,19 +135,6 @@ export function handleShapeImageWatermarkImport({ params, pict }) { return imageNode; } -/** - * Normalize a relationship target to a relative media path. - * @param {string} targetPath - * @returns {string} - */ -function normalizeTargetPath(targetPath = '') { - if (!targetPath) return targetPath; - const trimmed = targetPath.replace(/^\/+/, ''); - if (trimmed.startsWith('word/')) return trimmed; - if (trimmed.startsWith('media/')) return `word/${trimmed}`; - return `word/${trimmed}`; -} - /** * Parse VML inline style string into an object. * @param {string} style - VML style string (e.g., "width:100pt;height:50pt;margin-left:10pt") diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js index 3d444e1e54..53406f1e56 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js @@ -24,6 +24,7 @@ import { parseRelativeHeight } from './relative-height.js'; import { CHART_URI, resolveChartPart, parseChartXml } from './chart-helpers.js'; import { findChildByLocalName, someChildHasLocalName, hasLocalName, getLocalName } from './drawingml-utils.js'; import { importDrawingMLTextbox } from './import-drawingml-textbox.js'; +import { normalizeTargetPath } from '../../helpers/media-target-path.js'; const DRAWING_XML_TAG = 'w:drawing'; const SHAPE_URI = 'http://schemas.microsoft.com/office/word/2010/wordprocessingShape'; @@ -36,19 +37,6 @@ const GROUP_URI = 'http://schemas.microsoft.com/office/word/2010/wordprocessingG */ const SD_IMAGE_ID_NAMESPACE = '7c9e6679-7425-40de-944b-e07fc1f90ae7'; -/** - * Normalize a relationship target to a relative media path. - * Strips leading slashes and collapses duplicated "word/" prefixes so lookups - * match the media keys we store (e.g., "word/media/image.png"). - */ -const normalizeTargetPath = (targetPath = '') => { - if (!targetPath) return targetPath; - const trimmed = targetPath.replace(/^\/+/, ''); // remove leading slash(es) - if (trimmed.startsWith('word/')) return trimmed; - if (trimmed.startsWith('media/')) return `word/${trimmed}`; - return `word/${trimmed}`; -}; - /** * Default dimensions for vector shapes when size is not specified. * These values provide reasonable fallback dimensions while maintaining a square aspect ratio. diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js index cf1af95667..c0eb657790 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js @@ -1,5 +1,6 @@ import { emuToPixels, rotToDegrees } from '@converter/helpers.js'; import { findChildByLocalName, filterChildrenByLocalName, hasLocalName, getLocalName } from './drawingml-utils.js'; +import { normalizeTargetPath } from '../../helpers/media-target-path.js'; /** * Converts a preset color name (a:prstClr) to its hex value. @@ -213,14 +214,6 @@ function extractColorFromElement(element) { return null; } -const normalizeTargetPath = (targetPath = '') => { - if (!targetPath) return targetPath; - const trimmed = targetPath.replace(/^\/+/, ''); - if (trimmed.startsWith('word/')) return trimmed; - if (trimmed.startsWith('media/')) return `word/${trimmed}`; - return `word/${trimmed}`; -}; - const inferExtensionFromPath = (path = '') => { const match = String(path).match(/\.([a-zA-Z0-9]+)(?:[#?].*)?$/); return match?.[1]?.toLowerCase(); From cbbc38624f927e5643f325d56a7a0d6988a891a3 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 15 Jun 2026 14:00:00 -0300 Subject: [PATCH 26/30] fix: improve chart rendering fidelity for borders, gridlines, and legends Render the chart-area outline from c:spPr (honoring a:noFill to disable it), gate value-axis gridlines behind the OOXML majorGridlines flag instead of always drawing them, and lay out right-positioned legends as a column beside bar charts that reserve right-edge space. Thread per-chart padding through the bar layout so the right legend gets dedicated room, and align gridline color with the axis color. --- packages/layout-engine/contracts/src/index.ts | 2 + .../painters/dom/src/chart-renderer.test.ts | 86 +++++++++++- .../painters/dom/src/chart-renderer.ts | 129 +++++++++++------- .../v3/handlers/wp/helpers/chart-helpers.js | 17 +++ .../handlers/wp/helpers/chart-helpers.test.js | 11 ++ 5 files changed, 195 insertions(+), 50 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index e1b9263447..e16a5a6172 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1412,6 +1412,8 @@ export type ChartModel = { legendPosition?: string; /** OOXML chart style ID. */ styleId?: number; + /** Whether the chart area outline should be painted. */ + chartAreaBorder?: boolean; }; /** Chart drawing block. */ diff --git a/packages/layout-engine/painters/dom/src/chart-renderer.test.ts b/packages/layout-engine/painters/dom/src/chart-renderer.test.ts index 8b6e18b74b..3a181cb580 100644 --- a/packages/layout-engine/painters/dom/src/chart-renderer.test.ts +++ b/packages/layout-engine/painters/dom/src/chart-renderer.test.ts @@ -113,6 +113,20 @@ describe('createChartElement', () => { expect(el.querySelector('svg')).not.toBeNull(); }); + it('draws a chart area border around rendered charts', () => { + const el = createChartElement(doc, makeBarChart(), defaultGeometry); + expect(el.style.borderWidth).toBe('1px'); + expect(el.style.borderStyle).toBe('solid'); + expect(el.style.borderColor).toBe('rgb(191, 191, 191)'); + expect(el.style.boxSizing).toBe('border-box'); + }); + + it('omits the chart area border when the chart XML disables the outline', () => { + const el = createChartElement(doc, makeBarChart({ chartAreaBorder: false }), defaultGeometry); + expect(el.style.border).toBe(''); + expect(el.style.boxSizing).toBe('border-box'); + }); + it('shows placeholder for missing chart data', () => { const el = createChartElement(doc, undefined, defaultGeometry); expect(el.textContent).toContain('No chart data'); @@ -131,13 +145,77 @@ describe('createChartElement', () => { it('renders bars for each series value', () => { const el = createChartElement(doc, makeBarChart(), defaultGeometry); const rects = el.querySelectorAll('svg rect'); - // 3 data points = 3 bar rects (no legend swatch for single series with legendPosition) - // Wait — with the fix, legend shows for single series too. Let's check: - // Series 1 has 3 values → 3 bars - // Legend: 1 series with legendPosition → 1 swatch rect expect(rects.length).toBeGreaterThanOrEqual(3); }); + it('paints vertical bar gridlines behind bars', () => { + const el = createChartElement(doc, makeBarChart({ valueAxis: { majorGridlines: true } }), defaultGeometry); + const svg = el.querySelector('svg')!; + const children = Array.from(svg.children); + const firstGridlineIndex = children.findIndex( + (child) => child.tagName === 'line' && child.getAttribute('stroke-width') === '0.5', + ); + const firstBarIndex = children.findIndex( + (child) => child.tagName === 'rect' && child.getAttribute('fill') === '#4472C4', + ); + const axisLine = Array.from(svg.querySelectorAll('line')).find( + (line) => line.getAttribute('stroke-width') === '1', + )!; + const gridline = children[firstGridlineIndex]!; + + expect(firstGridlineIndex).toBeGreaterThanOrEqual(0); + expect(firstGridlineIndex).toBeLessThan(firstBarIndex); + expect(gridline.getAttribute('stroke')).toBe(axisLine.getAttribute('stroke')); + }); + + it('omits vertical bar gridlines when the value axis does not request them', () => { + const el = createChartElement(doc, makeBarChart({ valueAxis: undefined }), defaultGeometry); + const gridlines = Array.from(el.querySelectorAll('svg line')).filter( + (line) => line.getAttribute('stroke-width') === '0.5', + ); + + expect(gridlines).toHaveLength(0); + }); + + it('renders right-positioned legends as a column beside vertical bar charts', () => { + const el = createChartElement( + doc, + makeBarChart({ + legendPosition: 'r', + series: [ + { name: 'Series 1', categories: ['Q1'], values: [100] }, + { name: 'Series 2', categories: ['Q1'], values: [80] }, + { name: 'Series 3', categories: ['Q1'], values: [60] }, + ], + }), + defaultGeometry, + ); + const svg = el.querySelector('svg')!; + const legendLabels = ['Series 1', 'Series 2', 'Series 3'].map( + (name) => Array.from(svg.querySelectorAll('text')).find((text) => text.textContent === name)!, + ); + const xPositions = legendLabels.map((label) => Number(label.getAttribute('x'))); + const yPositions = legendLabels.map((label) => Number(label.getAttribute('y'))); + const barRightEdge = Math.max( + ...Array.from(svg.querySelectorAll('rect')) + .filter((rect) => rect.getAttribute('height') !== '10') + .map((rect) => Number(rect.getAttribute('x')) + Number(rect.getAttribute('width'))), + ); + + expect(new Set(xPositions).size).toBe(1); + expect(xPositions[0]).toBeGreaterThan(barRightEdge); + expect(yPositions[1] - yPositions[0]).toBeGreaterThan(0); + expect(yPositions[2] - yPositions[1]).toBeGreaterThan(0); + }); + + it('keeps right-positioned non-bar legends on the bottom until their plots reserve right space', () => { + const el = createChartElement(doc, makeBubbleChart({ legendPosition: 'r' }), defaultGeometry); + const legendLabel = Array.from(el.querySelectorAll('svg text')).find((text) => text.textContent === 'Series 1')!; + + expect(Number(legendLabel.getAttribute('x'))).toBe(74); + expect(Number(legendLabel.getAttribute('y'))).toBe(defaultGeometry.height - 12); + }); + it('renders horizontal bar charts with category labels and in-bar percentage data labels', () => { const el = createChartElement( doc, diff --git a/packages/layout-engine/painters/dom/src/chart-renderer.ts b/packages/layout-engine/painters/dom/src/chart-renderer.ts index a7d070b6d0..5bb70a01aa 100644 --- a/packages/layout-engine/painters/dom/src/chart-renderer.ts +++ b/packages/layout-engine/painters/dom/src/chart-renderer.ts @@ -40,6 +40,7 @@ const SVG_NS = 'http://www.w3.org/2000/svg'; const CHART_PADDING = { top: 30, right: 20, bottom: 50, left: 60 }; const HORIZONTAL_CHART_PADDING = { top: 2, right: 20, bottom: 2, left: 60 }; +const RIGHT_LEGEND_WIDTH = 110; const VALUE_TICK_COUNT = 5; const DATA_LABEL_PADDING = 4; @@ -61,6 +62,8 @@ export function createChartElement( container.style.width = '100%'; container.style.height = '100%'; container.style.position = 'relative'; + if (chartData?.chartAreaBorder !== false) container.style.border = '1px solid #BFBFBF'; + container.style.boxSizing = 'border-box'; if (!chartData || !chartData.series?.length) { return createChartPlaceholder(doc, container, 'No chart data'); @@ -138,6 +141,7 @@ export function formatTickValue(value: number): string { } type BarChartLayout = { + padding: typeof CHART_PADDING; plotWidth: number; plotHeight: number; groupWidth: number; @@ -150,6 +154,7 @@ type BarChartLayout = { }; type HorizontalBarChartLayout = { + padding: typeof HORIZONTAL_CHART_PADDING; plotLeft: number; plotTop: number; plotWidth: number; @@ -200,9 +205,21 @@ function applyGuardrails(chart: ChartModel): { series: ChartSeriesData[]; trunca return { series, truncated }; } -function computeBarLayout(width: number, height: number, series: ChartSeriesData[]): BarChartLayout { - const plotWidth = Math.max(1, width - CHART_PADDING.left - CHART_PADDING.right); - const plotHeight = Math.max(1, height - CHART_PADDING.top - CHART_PADDING.bottom); +function getBarChartPadding(chart: ChartModel): typeof CHART_PADDING { + return { + ...CHART_PADDING, + right: chart.legendPosition === 'r' ? RIGHT_LEGEND_WIDTH : CHART_PADDING.right, + }; +} + +function computeBarLayout( + width: number, + height: number, + series: ChartSeriesData[], + padding: typeof CHART_PADDING, +): BarChartLayout { + const plotWidth = Math.max(1, width - padding.left - padding.right); + const plotHeight = Math.max(1, height - padding.top - padding.bottom); const allValues = series.flatMap((s) => s.values); const maxValue = Math.max(0, ...allValues); @@ -217,20 +234,21 @@ function computeBarLayout(width: number, height: number, series: ChartSeriesData const barGap = Math.max(1, groupWidth * 0.1); const totalBarWidth = groupWidth - barGap * 2; const barWidth = Math.max(1, totalBarWidth / seriesCount); - const baselineY = CHART_PADDING.top + plotHeight * (maxValue / valueRange); + const baselineY = padding.top + plotHeight * (maxValue / valueRange); - return { plotWidth, plotHeight, groupWidth, barWidth, barGap, baselineY, valueRange, minValue, maxValue }; + return { padding, plotWidth, plotHeight, groupWidth, barWidth, barGap, baselineY, valueRange, minValue, maxValue }; } function computeHorizontalBarLayout( width: number, height: number, series: ChartSeriesData[], - gapWidth?: number, + chart: ChartModel, hasValueAxisLabels = false, ): HorizontalBarChartLayout { const padding = { ...HORIZONTAL_CHART_PADDING, + right: chart.legendPosition === 'r' ? RIGHT_LEGEND_WIDTH : HORIZONTAL_CHART_PADDING.right, bottom: hasValueAxisLabels ? 24 : HORIZONTAL_CHART_PADDING.bottom, }; const plotLeft = padding.left; @@ -248,7 +266,7 @@ function computeHorizontalBarLayout( const seriesCount = series.length; const groupHeight = plotHeight / categoryCount; - const gapRatio = Math.max(0, Math.min(5, (gapWidth ?? 150) / 100)); + const gapRatio = Math.max(0, Math.min(5, (chart.gapWidth ?? 150) / 100)); const barHeight = Math.max(1, groupHeight / (seriesCount + gapRatio)); const barGap = Math.max(0, (groupHeight - barHeight * seriesCount) / 2); const baselineX = plotLeft + plotWidth * ((0 - minValue) / valueRange); @@ -265,6 +283,7 @@ function computeHorizontalBarLayout( valueRange, minValue, maxValue, + padding, }; } @@ -279,7 +298,7 @@ function toHorizontalDisplaySeries(chart: ChartModel, series: ChartSeriesData[]) } function renderBars(doc: Document, svg: SVGSVGElement, series: ChartSeriesData[], layout: BarChartLayout): void { - const { groupWidth, barGap, barWidth, baselineY, valueRange, plotHeight } = layout; + const { padding, groupWidth, barGap, barWidth, baselineY, valueRange, plotHeight } = layout; for (let si = 0; si < series.length; si++) { const s = series[si]!; @@ -288,7 +307,7 @@ function renderBars(doc: Document, svg: SVGSVGElement, series: ChartSeriesData[] for (let ci = 0; ci < s.values.length; ci++) { const value = s.values[ci]!; const barHeight = Math.abs(value / valueRange) * plotHeight; - const x = CHART_PADDING.left + ci * groupWidth + barGap + si * barWidth; + const x = padding.left + ci * groupWidth + barGap + si * barWidth; const y = value >= 0 ? baselineY - barHeight : baselineY; const rect = doc.createElementNS(SVG_NS, 'rect'); @@ -456,23 +475,23 @@ function renderHorizontalBars( } function renderAxes(doc: Document, svg: SVGSVGElement, layout: BarChartLayout): void { - const { plotWidth, plotHeight, baselineY } = layout; + const { padding, plotWidth, plotHeight, baselineY } = layout; // Vertical axis const vAxis = doc.createElementNS(SVG_NS, 'line'); - vAxis.setAttribute('x1', String(CHART_PADDING.left)); - vAxis.setAttribute('y1', String(CHART_PADDING.top)); - vAxis.setAttribute('x2', String(CHART_PADDING.left)); - vAxis.setAttribute('y2', String(CHART_PADDING.top + plotHeight)); + vAxis.setAttribute('x1', String(padding.left)); + vAxis.setAttribute('y1', String(padding.top)); + vAxis.setAttribute('x2', String(padding.left)); + vAxis.setAttribute('y2', String(padding.top + plotHeight)); vAxis.setAttribute('stroke', AXIS_COLOR); vAxis.setAttribute('stroke-width', '1'); svg.appendChild(vAxis); // Horizontal baseline const hAxis = doc.createElementNS(SVG_NS, 'line'); - hAxis.setAttribute('x1', String(CHART_PADDING.left)); + hAxis.setAttribute('x1', String(padding.left)); hAxis.setAttribute('y1', String(baselineY)); - hAxis.setAttribute('x2', String(CHART_PADDING.left + plotWidth)); + hAxis.setAttribute('x2', String(padding.left + plotWidth)); hAxis.setAttribute('y2', String(baselineY)); hAxis.setAttribute('stroke', AXIS_COLOR); hAxis.setAttribute('stroke-width', '1'); @@ -486,15 +505,15 @@ function renderCategoryLabels( layout: BarChartLayout, width: number, ): void { - const { groupWidth, plotHeight } = layout; + const { padding, groupWidth, plotHeight } = layout; const categoryCount = Math.max(1, categories.length); const fontSize = Math.max(8, Math.min(12, width / categoryCount / 5)); for (let ci = 0; ci < categories.length; ci++) { - const labelX = CHART_PADDING.left + ci * groupWidth + groupWidth / 2; + const labelX = padding.left + ci * groupWidth + groupWidth / 2; const label = doc.createElementNS(SVG_NS, 'text'); label.setAttribute('x', String(labelX)); - label.setAttribute('y', String(CHART_PADDING.top + plotHeight + 16)); + label.setAttribute('y', String(padding.top + plotHeight + 16)); label.setAttribute('text-anchor', 'middle'); label.setAttribute('font-size', String(fontSize)); label.setAttribute('fill', LABEL_COLOR); @@ -504,17 +523,23 @@ function renderCategoryLabels( } } -function renderValueTicks(doc: Document, svg: SVGSVGElement, layout: BarChartLayout, height: number): void { - const { plotWidth, plotHeight, valueRange, minValue } = layout; +function renderValueTicks( + doc: Document, + svg: SVGSVGElement, + layout: BarChartLayout, + height: number, + showGridlines: boolean, +): void { + const { padding, plotWidth, plotHeight, valueRange, minValue } = layout; const tickStep = valueRange / VALUE_TICK_COUNT; const fontSize = Math.max(8, Math.min(11, height / 30)); for (let i = 0; i <= VALUE_TICK_COUNT; i++) { const tickValue = minValue + tickStep * i; - const tickY = CHART_PADDING.top + plotHeight - (plotHeight * (tickValue - minValue)) / valueRange; + const tickY = padding.top + plotHeight - (plotHeight * (tickValue - minValue)) / valueRange; const label = doc.createElementNS(SVG_NS, 'text'); - label.setAttribute('x', String(CHART_PADDING.left - 6)); + label.setAttribute('x', String(padding.left - 6)); label.setAttribute('y', String(tickY + 3)); label.setAttribute('text-anchor', 'end'); label.setAttribute('font-size', String(fontSize)); @@ -523,13 +548,13 @@ function renderValueTicks(doc: Document, svg: SVGSVGElement, layout: BarChartLay label.textContent = formatTickValue(tickValue); svg.appendChild(label); - if (i > 0 && i < VALUE_TICK_COUNT) { + if (showGridlines && i > 0 && i < VALUE_TICK_COUNT) { const gridLine = doc.createElementNS(SVG_NS, 'line'); - gridLine.setAttribute('x1', String(CHART_PADDING.left)); + gridLine.setAttribute('x1', String(padding.left)); gridLine.setAttribute('y1', String(tickY)); - gridLine.setAttribute('x2', String(CHART_PADDING.left + plotWidth)); + gridLine.setAttribute('x2', String(padding.left + plotWidth)); gridLine.setAttribute('y2', String(tickY)); - gridLine.setAttribute('stroke', GRID_COLOR); + gridLine.setAttribute('stroke', AXIS_COLOR); gridLine.setAttribute('stroke-width', '0.5'); svg.appendChild(gridLine); } @@ -591,16 +616,24 @@ function renderHorizontalValueTicks( gridLine.setAttribute('y1', String(plotTop)); gridLine.setAttribute('x2', String(tickX)); gridLine.setAttribute('y2', String(plotTop + plotHeight)); - gridLine.setAttribute('stroke', GRID_COLOR); + gridLine.setAttribute('stroke', AXIS_COLOR); gridLine.setAttribute('stroke-width', '0.5'); svg.appendChild(gridLine); } } } -function renderLegend(doc: Document, svg: SVGSVGElement, series: ChartSeriesData[], height: number): void { - const legendY = height - 12; - let legendX = CHART_PADDING.left; +function renderLegend( + doc: Document, + svg: SVGSVGElement, + series: ChartSeriesData[], + width: number, + height: number, + position: string | undefined, +): void { + const isRightLegend = position === 'r'; + let legendX = isRightLegend ? width - RIGHT_LEGEND_WIDTH + 12 : CHART_PADDING.left; + let legendY = isRightLegend ? Math.max(CHART_PADDING.top + 12, height / 2 - (series.length * 18) / 2) : height - 12; for (let si = 0; si < series.length; si++) { const s = series[si]!; @@ -623,7 +656,11 @@ function renderLegend(doc: Document, svg: SVGSVGElement, series: ChartSeriesData label.textContent = s.name; svg.appendChild(label); - legendX += 14 + s.name.length * 6 + 16; + if (isRightLegend) { + legendY += 18; + } else { + legendX += 14 + s.name.length * 6 + 16; + } } } @@ -964,7 +1001,7 @@ function renderScatterChart( } if (hasLegend) { - renderLegend(doc, svg, series, height); + renderLegend(doc, svg, series, width, height, undefined); } if (truncated) { renderTruncationIndicator(doc, svg, width); @@ -1027,7 +1064,7 @@ function renderBubbleChart( } if (hasLegend) { - renderLegend(doc, svg, series, height); + renderLegend(doc, svg, series, width, height, undefined); } if (truncated) { renderTruncationIndicator(doc, svg, width); @@ -1163,7 +1200,7 @@ function renderRadarChart( } if (hasLegend) { - renderLegend(doc, svg, series, height); + renderLegend(doc, svg, series, width, height, undefined); } if (truncated) { renderTruncationIndicator(doc, svg, width); @@ -1408,7 +1445,7 @@ function renderLineChart( } if (hasLegend) { - renderLegend(doc, svg, series, height); + renderLegend(doc, svg, series, width, height, undefined); } if (truncated) { renderTruncationIndicator(doc, svg, width); @@ -1470,7 +1507,7 @@ function renderAreaChart( } if (hasLegend) { - renderLegend(doc, svg, series, height); + renderLegend(doc, svg, series, width, height, undefined); } if (truncated) { renderTruncationIndicator(doc, svg, width); @@ -1626,7 +1663,7 @@ function estimateSvgElements( const axes = 2; const valueTicks = VALUE_TICK_COUNT + 1; - const gridLines = VALUE_TICK_COUNT - 1; + const gridLines = chart.valueAxis?.majorGridlines === true ? VALUE_TICK_COUNT - 1 : 0; return bars + axes + categoryLabels + valueTicks + gridLines + dataLabels + legend; } @@ -1658,18 +1695,18 @@ function renderBarChart( const valueAxisDeleted = chart.valueAxis?.deleted === true; const horizontalSeries = toHorizontalDisplaySeries(chart, series); const horizontalCategories = horizontalSeries[0]?.categories ?? []; - const layout = computeHorizontalBarLayout(width, height, horizontalSeries, chart.gapWidth, !valueAxisDeleted); + const layout = computeHorizontalBarLayout(width, height, horizontalSeries, chart, !valueAxisDeleted); + if (!valueAxisDeleted) { + renderHorizontalValueTicks(doc, svg, layout, height, chart.valueAxis?.majorGridlines === true); + } renderHorizontalBars(doc, svg, horizontalSeries, layout); if (chart.categoryAxis?.deleted !== true) { renderHorizontalCategoryLabels(doc, svg, horizontalCategories, layout, width); } - if (!valueAxisDeleted) { - renderHorizontalValueTicks(doc, svg, layout, height, chart.valueAxis?.majorGridlines === true); - } if (hasLegend) { - renderLegend(doc, svg, series, height); + renderLegend(doc, svg, series, width, height, chart.legendPosition); } if (truncated) { @@ -1680,15 +1717,15 @@ function renderBarChart( return container; } - const layout = computeBarLayout(width, height, series); + const layout = computeBarLayout(width, height, series, getBarChartPadding(chart)); + renderValueTicks(doc, svg, layout, height, chart.valueAxis?.majorGridlines === true); renderBars(doc, svg, series, layout); renderAxes(doc, svg, layout); renderCategoryLabels(doc, svg, categories, layout, width); - renderValueTicks(doc, svg, layout, height); if (hasLegend) { - renderLegend(doc, svg, series, height); + renderLegend(doc, svg, series, width, height, chart.legendPosition); } if (truncated) { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/chart-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/chart-helpers.js index 32b9ce19ae..954c366c66 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/chart-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/chart-helpers.js @@ -172,6 +172,7 @@ export function parseChartXml(chartXml) { const valueAxis = parseAxis(plotArea, 'c:valAx'); const legendPosition = parseLegendPosition(chart); const styleId = parseStyleId(chartSpace); + const chartAreaBorder = parseChartAreaBorder(chartSpace); return { chartType, @@ -183,6 +184,7 @@ export function parseChartXml(chartXml) { ...(valueAxis && { valueAxis }), ...(legendPosition && { legendPosition }), ...(styleId != null && { styleId }), + ...(chartAreaBorder != null && { chartAreaBorder }), }; } @@ -454,6 +456,21 @@ function parseLegendPosition(chart) { return getAttr(legendPos, 'val') || undefined; } +/** + * Parse chart-space outline visibility from c:spPr. + * @param {Object} chartSpace - c:chartSpace element + * @returns {boolean|undefined} + */ +function parseChartAreaBorder(chartSpace) { + const spPr = findChild(chartSpace, 'c:spPr'); + if (!spPr) return undefined; + + const line = findChild(spPr, 'a:ln'); + if (!line) return undefined; + + return !findChild(line, 'a:noFill'); +} + /** * Parse chart style ID from chartSpace. * Checks mc:AlternateContent for c14:style, then mc:Fallback c:style, then direct c:style. diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/chart-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/chart-helpers.test.js index 6be5290be9..05afb548d7 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/chart-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/chart-helpers.test.js @@ -287,6 +287,17 @@ describe('chart-helpers', () => { expect(result.styleId).toBe(2); }); + it('parses a disabled chart area border from chart-space shape properties', () => { + const xml = makeBarChartXml(); + xml.elements.push({ + name: 'c:spPr', + elements: [{ name: 'a:ln', elements: [{ name: 'a:noFill' }] }], + }); + + const result = parseChartXml(xml); + expect(result.chartAreaBorder).toBe(false); + }); + it('prefers c14 style in mc:AlternateContent when present', () => { const result = parseChartXml(makeBarChartXmlWithAlternateContentStyle({ choiceStyle: 102, fallbackStyle: 2 })); expect(result.styleId).toBe(102); From 3fb4f43bff0e404b3464560622aff81b5db927f8 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 23 Jun 2026 16:38:23 -0300 Subject: [PATCH 27/30] refactor: import connector utils directly from preset-geometry Drop the connector re-export barrel in shared/svg-utils.js and have ShapeGroupView, VectorShapeView, and the svg-utils test import connector helpers directly from @superdoc/preset-geometry/connectors. --- .../v1/extensions/shape-group/ShapeGroupView.js | 4 +++- .../editors/v1/extensions/shared/svg-utils.js | 17 ----------------- .../v1/extensions/shared/svg-utils.test.js | 10 ++-------- .../extensions/vector-shape/VectorShapeView.js | 10 ++++++---- 4 files changed, 11 insertions(+), 30 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js index 9ba5cff223..b4617e5064 100644 --- a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js +++ b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js @@ -3,11 +3,13 @@ import { getPresetShapeSvg } from '@superdoc/preset-geometry'; import { applyNonScalingStrokeToConnectorTarget, createConnectorPresetSvg, + isConnectorPresetShape, +} from '@superdoc/preset-geometry/connectors'; +import { createGradient, createLineEndMarker, createPictureFillPattern, createTextElement, - isConnectorPresetShape, } from '../shared/svg-utils.js'; import { OOXML_Z_INDEX_BASE } from '@extensions/shared/constants.js'; diff --git a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js index ac6c37b3cf..8c6aa7bb23 100644 --- a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js @@ -1,26 +1,9 @@ import { formatChapterPageNumberText, formatPageNumber } from '@superdoc/contracts'; import { - applyNonScalingStrokeToConnector, - applyNonScalingStrokeToConnectorTarget, - createConnectorPresetSvg, createLineEndMarker as createLineEndMarkerWithDocument, createLineEndShape as createLineEndShapeWithDocument, - formatSvgNumber, - getConnectorPresetPath, - getConnectorStrokePadding, - isConnectorPresetShape, } from '@superdoc/preset-geometry/connectors'; -export { - applyNonScalingStrokeToConnector, - applyNonScalingStrokeToConnectorTarget, - createConnectorPresetSvg, - formatSvgNumber, - getConnectorPresetPath, - getConnectorStrokePadding, - isConnectorPresetShape, -}; - export function createLineEndShape(type, strokeColor, isStart) { return createLineEndShapeWithDocument(document, type, strokeColor, isStart); } diff --git a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js index 86c57d0104..845e294c4d 100644 --- a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js @@ -2,14 +2,8 @@ * @vitest-environment jsdom */ import { describe, it, expect, beforeEach } from 'vitest'; -import { - createTextElement, - createGradient, - createPictureFillPattern, - generateTransforms, - getConnectorPresetPath, - createConnectorPresetSvg, -} from './svg-utils.js'; +import { getConnectorPresetPath, createConnectorPresetSvg } from '@superdoc/preset-geometry/connectors'; +import { createTextElement, createGradient, createPictureFillPattern, generateTransforms } from './svg-utils.js'; describe('svg-utils', () => { describe('createPictureFillPattern', () => { diff --git a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js index 5160fcbad3..c93ae3fb26 100644 --- a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js +++ b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js @@ -1,5 +1,11 @@ // @ts-expect-error - preset-geometry package may not have type definitions import { getPresetShapeSvg } from '@superdoc/preset-geometry'; +import { + applyNonScalingStrokeToConnector, + createConnectorPresetSvg, + formatSvgNumber, + isConnectorPresetShape, +} from '@superdoc/preset-geometry/connectors'; import { inchesToPixels } from '@converter/helpers.js'; import { OOXML_Z_INDEX_BASE } from '@extensions/shared/constants.js'; import { @@ -8,12 +14,8 @@ import { applyGradientToSVG, applyAlphaToSVG, generateTransforms, - applyNonScalingStrokeToConnector, - createConnectorPresetSvg, createLineEndMarker, createPictureFillPattern, - formatSvgNumber, - isConnectorPresetShape, } from '../shared/svg-utils.js'; export class VectorShapeView { From 0c8ce93fc6240cf5edac3d844ae5b74eaafe6875 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 26 Jun 2026 16:13:52 -0300 Subject: [PATCH 28/30] fix: gate header/footer X normalization on horizontal anchor normalizeFragmentsForRegion was rewriting column-relative X whenever the vertical anchor was page-relative, conflating the two independent OOXML axes. Switch the gate to the horizontal anchor (hRelativeFrom === 'page') so column-relative X stays content-local and the painter applies the physical page margin exactly once. Update tests to cover both axes independently. --- .../src/normalize-header-footer-fragments.test.ts | 9 +++++---- .../src/normalize-header-footer-fragments.ts | 5 ++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts index 7129871ca0..7d49971bbe 100644 --- a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts +++ b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts @@ -107,7 +107,7 @@ describe('normalizeFragmentsForRegion (header/footer page-relative anchors)', () expect(fragment.x).toBe(24); }); - it('normalizes column-relative X using the physical page margin when Y is page-relative', () => { + it('does not normalize column-relative X when Y is page-relative', () => { const imgWidth = 830; const block: FlowBlock = { kind: 'image', @@ -123,11 +123,12 @@ describe('normalizeFragmentsForRegion (header/footer page-relative anchors)', () }, }; const fragment = makeAnchoredImageFragment('header-column-background', 0, 40, imgWidth); + fragment.x = -76; const pages = [{ number: 1, fragments: [fragment] }]; normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'header', fullConstraints); - expect(fragment.x).toBe(-4); + expect(fragment.x).toBe(-76); expect(fragment.y).toBe(8); }); }); @@ -207,7 +208,7 @@ describe('normalizeFragmentsForRegion (header/footer page-relative anchors)', () expect(fragment.y).toBe(PAGE_HEIGHT - 40 - FOOTER_BAND_ORIGIN); }); - it('does not rewrite page-relative X when the vertical anchor is not page-relative', () => { + it('normalizes page-relative X when the vertical anchor is not page-relative', () => { const block: FlowBlock = { kind: 'image', id: 'footer-cross-axis', @@ -226,7 +227,7 @@ describe('normalizeFragmentsForRegion (header/footer page-relative anchors)', () normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'footer', fullConstraints); - expect(fragment.x).toBe(24); + expect(fragment.x).toBe((816 - 120) / 2); expect(fragment.y).toBe(12); }); diff --git a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts index 5d4cbfc5e2..9f53a130c3 100644 --- a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts +++ b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts @@ -72,6 +72,7 @@ function isAnchoredFragment(fragment: Fragment): boolean { } function isPageRelativeBlock(block: FlowBlock): block is ImageBlock | DrawingBlock { + // This is a union-of-axes gate; each normalization branch re-checks its own axis. return ( (block.kind === 'image' || block.kind === 'drawing') && (block.anchor?.hRelativeFrom === 'page' || block.anchor?.vRelativeFrom === 'page') @@ -121,7 +122,9 @@ export function normalizeFragmentsForRegion( const block = blockById.get(fragment.blockId); if (!block || !isPageRelativeBlock(block)) continue; - if (pageWidth != null && block.anchor?.vRelativeFrom === 'page') { + // Horizontal and vertical anchors are independent in OOXML. Keep column-relative X + // content-local so the painter can add the physical page margin exactly once. + if (pageWidth != null && block.anchor?.hRelativeFrom === 'page') { const fragmentWidth = (fragment as { width?: number }).width ?? 0; const marginLeft = Math.max(0, constraints.margins.left ?? 0); const marginRight = Math.max(0, constraints.margins.right ?? 0); From aa43234fb6c7c61d54d560967007b082fd14c68d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 26 Jun 2026 17:57:43 -0300 Subject: [PATCH 29/30] fix(layout): keep header footer anchors container-local --- .../layout-engine/src/index.test.ts | 1 + .../normalize-header-footer-fragments.test.ts | 55 +++++++++++++++++++ .../src/normalize-header-footer-fragments.ts | 22 +++++++- 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index c3082b4124..2ae12ca73f 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -5223,6 +5223,7 @@ describe('layoutHeaderFooter', () => { offsetH: 0, offsetV: 0, }, + wrap: { type: 'None' }, }; const imageMeasure: Measure = { kind: 'image', diff --git a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts index 7d49971bbe..45c913daf3 100644 --- a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts +++ b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.test.ts @@ -52,6 +52,7 @@ describe('normalizeFragmentsForRegion (header/footer page-relative anchors)', () offsetH: 0, offsetV: 0, }, + wrap: { type: 'None' }, }; const fragment = makeAnchoredImageFragment('header-background', 0, imgHeight, imgWidth); const pages = [{ number: 1, fragments: [fragment] }]; @@ -76,6 +77,7 @@ describe('normalizeFragmentsForRegion (header/footer page-relative anchors)', () alignV: 'top', offsetH: 12, }, + wrap: { type: 'None' }, }; const fragment = makeAnchoredImageFragment('header-logo', 0, 40, imgWidth); const pages = [{ number: 1, fragments: [fragment] }]; @@ -98,6 +100,7 @@ describe('normalizeFragmentsForRegion (header/footer page-relative anchors)', () alignV: 'top', offsetH: 24, }, + wrap: { type: 'None' }, }; const fragment = makeAnchoredImageFragment('header-left-offset', 0, 40, 120); const pages = [{ number: 1, fragments: [fragment] }]; @@ -107,6 +110,56 @@ describe('normalizeFragmentsForRegion (header/footer page-relative anchors)', () expect(fragment.x).toBe(24); }); + it('keeps normal page-relative anchors container-local on the horizontal axis', () => { + const imgWidth = 120; + const block: FlowBlock = { + kind: 'image', + id: 'header-normal-centered', + src: 'test.png', + anchor: { + isAnchored: true, + hRelativeFrom: 'page', + alignH: 'center', + vRelativeFrom: 'page', + alignV: 'top', + offsetV: 0, + }, + wrap: { type: 'Square' }, + }; + const fragment = makeAnchoredImageFragment('header-normal-centered', 0, 40, imgWidth); + const pages = [{ number: 1, fragments: [fragment] }]; + + normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'header', fullConstraints); + + expect(fragment.x).toBe((816 - imgWidth) / 2 - fullConstraints.margins.left); + expect(fragment.y).toBe(0); + }); + + it('keeps legacy zIndex zero page-relative anchors in physical page coordinates', () => { + const imgWidth = 120; + const block: FlowBlock = { + kind: 'image', + id: 'header-legacy-behind', + src: 'test.png', + anchor: { + isAnchored: true, + hRelativeFrom: 'page', + alignH: 'center', + vRelativeFrom: 'page', + alignV: 'top', + offsetV: 0, + }, + }; + const fragment = makeAnchoredImageFragment('header-legacy-behind', 0, 40, imgWidth); + (fragment as { behindDoc?: boolean }).behindDoc = undefined; + (fragment as { zIndex?: number }).zIndex = 0; + const pages = [{ number: 1, fragments: [fragment] }]; + + normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'header', fullConstraints); + + expect(fragment.x).toBe((816 - imgWidth) / 2); + }); + it('does not normalize column-relative X when Y is page-relative', () => { const imgWidth = 830; const block: FlowBlock = { @@ -198,6 +251,7 @@ describe('normalizeFragmentsForRegion (header/footer page-relative anchors)', () alignV: 'bottom', offsetV: 0, }, + wrap: { type: 'None' }, }; const fragment = makeAnchoredImageFragment('footer-centered', 0, 40, imgWidth); const pages = [{ number: 1, fragments: [fragment] }]; @@ -220,6 +274,7 @@ describe('normalizeFragmentsForRegion (header/footer page-relative anchors)', () vRelativeFrom: 'paragraph', offsetV: 0, }, + wrap: { type: 'None' }, }; const fragment = makeAnchoredImageFragment('footer-cross-axis', 12, 40, 120); fragment.x = 24; diff --git a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts index 9f53a130c3..7d492bc610 100644 --- a/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts +++ b/packages/layout-engine/layout-engine/src/normalize-header-footer-fragments.ts @@ -79,6 +79,25 @@ function isPageRelativeBlock(block: FlowBlock): block is ImageBlock | DrawingBlo ); } +function rendersInNormalHeaderFooterContainer( + block: ImageBlock | DrawingBlock, + fragment: Fragment, + kind: 'header' | 'footer', +): boolean { + const mediaFragment = fragment as { behindDoc?: boolean; zIndex?: number }; + if ( + mediaFragment.behindDoc === true || + (mediaFragment.behindDoc == null && mediaFragment.zIndex === 0) || + block.anchor?.behindDoc === true + ) { + return false; + } + if (kind === 'header' && block.kind === 'image' && block.attrs?.vmlTextWatermark === true) { + return false; + } + return block.wrap?.type !== 'None'; +} + /** * Post-normalize page-relative anchored fragment positions in header/footer layout. * @@ -129,7 +148,7 @@ export function normalizeFragmentsForRegion( const marginLeft = Math.max(0, constraints.margins.left ?? 0); const marginRight = Math.max(0, constraints.margins.right ?? 0); const contentWidth = Math.max(1, pageWidth - (marginLeft + marginRight)); - fragment.x = resolveAnchoredGraphicX( + const physicalX = resolveAnchoredGraphicX( block.anchor ?? {}, 0, { width: contentWidth, gap: 0, count: 1 }, @@ -137,6 +156,7 @@ export function normalizeFragmentsForRegion( { left: marginLeft, right: marginRight }, pageWidth, ); + fragment.x = rendersInNormalHeaderFooterContainer(block, fragment, _kind) ? physicalX - marginLeft : physicalX; } if (block.anchor?.vRelativeFrom === 'page') { From 90792771b15ecb8a5d8e7864415142541282f869 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 26 Jun 2026 17:57:55 -0300 Subject: [PATCH 30/30] fix(charts): count vertical labels in svg budget --- .../painters/dom/src/chart-renderer.test.ts | 20 +++++++++++++++++++ .../painters/dom/src/chart-renderer.ts | 5 +++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/chart-renderer.test.ts b/packages/layout-engine/painters/dom/src/chart-renderer.test.ts index 3a181cb580..fb1dddcdbe 100644 --- a/packages/layout-engine/painters/dom/src/chart-renderer.test.ts +++ b/packages/layout-engine/painters/dom/src/chart-renderer.test.ts @@ -460,6 +460,26 @@ describe('performance guardrails', () => { // Should NOT have an SVG — it's a placeholder expect(el.querySelector('svg')).toBeNull(); }); + + it('counts vertical category labels in the SVG budget even when the category axis is deleted', () => { + // 9 series × 500 points = 4,500 bars. The vertical renderer still paints + // 500 category labels, so this must exceed the 5,000 element budget. + const categories = Array.from({ length: 500 }, (_, i) => `C${i}`); + const series = Array.from({ length: 9 }, (_, i) => ({ + name: `S${i}`, + categories, + values: categories.map((_, j) => i + j), + })); + const chart = makeBarChart({ + legendPosition: undefined, + categoryAxis: { deleted: true }, + series, + }); + const el = createChartElement(doc, chart, defaultGeometry); + + expect(el.textContent).toContain('too complex'); + expect(el.querySelector('svg')).toBeNull(); + }); }); describe('createChartPlaceholder', () => { diff --git a/packages/layout-engine/painters/dom/src/chart-renderer.ts b/packages/layout-engine/painters/dom/src/chart-renderer.ts index 5bb70a01aa..dd262908e7 100644 --- a/packages/layout-engine/painters/dom/src/chart-renderer.ts +++ b/packages/layout-engine/painters/dom/src/chart-renderer.ts @@ -1650,15 +1650,16 @@ function estimateSvgElements( ): number { const bars = series.reduce((sum, s) => sum + s.values.length, 0); const isHorizontalBar = chart.chartType === 'barChart' && chart.barDirection === 'bar'; - const categoryLabels = chart.categoryAxis?.deleted === true ? 0 : categories.length; + const categoryLabels = categories.length; const dataLabels = series.reduce((sum, s) => (s.dataLabels?.showValue ? sum + s.values.length : sum), 0); const legend = hasLegend ? series.length * 2 : 0; // swatch + label per series if (isHorizontalBar) { + const horizontalCategoryLabels = chart.categoryAxis?.deleted === true ? 0 : categoryLabels; const valueAxisDeleted = chart.valueAxis?.deleted === true; const valueLabels = valueAxisDeleted ? 0 : VALUE_TICK_COUNT + 1; const gridLines = !valueAxisDeleted && chart.valueAxis?.majorGridlines === true ? VALUE_TICK_COUNT - 1 : 0; - return bars + categoryLabels + dataLabels + valueLabels + gridLines + legend; + return bars + horizontalCategoryLabels + dataLabels + valueLabels + gridLines + legend; } const axes = 2;