diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index ae60be84c2..e16a5a6172 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: @@ -1361,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. */ @@ -1377,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. */ @@ -1387,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/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 0a307a6b71..2ae12ca73f 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -5209,43 +5209,50 @@ 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, }, + wrap: { type: 'None' }, }; 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..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 @@ -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,158 @@ 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, + }, + wrap: { type: 'None' }, + }; + 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); + }); + + 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', + vRelativeFrom: 'page', + alignV: 'top', + offsetH: 12, + }, + wrap: { type: 'None' }, + }; + 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', + vRelativeFrom: 'page', + alignV: 'top', + offsetH: 24, + }, + wrap: { type: 'None' }, + }; + 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); + }); + + 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 = { + 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); + fragment.x = -76; + const pages = [{ number: 1, fragments: [fragment] }]; + + normalizeFragmentsForRegion(pages, [block], [makeDummyMeasure()], 'header', fullConstraints); + + expect(fragment.x).toBe(-76); + expect(fragment.y).toBe(8); + }); + }); + describe('page-relative anchors in footer', () => { it('normalizes a top-aligned anchor', () => { const block: FlowBlock = { @@ -85,6 +237,55 @@ describe('normalizeFragmentsForRegion (footer page-relative only)', () => { 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, + }, + wrap: { type: 'None' }, + }; + 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('normalizes 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, + }, + wrap: { type: 'None' }, + }; + 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((816 - 120) / 2); + 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 8ebd93882c..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 @@ -7,11 +7,13 @@ 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. */ export type RegionConstraints = { + pageWidth?: number; pageHeight?: number; margins?: { left: number; @@ -58,6 +60,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 +72,46 @@ function isAnchoredFragment(fragment: Fragment): boolean { } function isPageRelativeBlock(block: FlowBlock): block is ImageBlock | DrawingBlock { - return (block.kind === 'image' || block.kind === 'drawing') && block.anchor?.vRelativeFrom === 'page'; + // 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') + ); +} + +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 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 +126,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 +141,29 @@ 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; + // 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); + const contentWidth = Math.max(1, pageWidth - (marginLeft + marginRight)); + const physicalX = resolveAnchoredGraphicX( + block.anchor ?? {}, + 0, + { width: contentWidth, gap: 0, count: 1 }, + fragmentWidth, + { left: marginLeft, right: marginRight }, + pageWidth, + ); + fragment.x = rendersInNormalHeaderFooterContainer(block, fragment, _kind) ? physicalX - marginLeft : physicalX; + } + + if (block.anchor?.vRelativeFrom === 'page') { + const fragmentHeight = (fragment as { height?: number }).height ?? 0; + const physicalY = computePhysicalAnchorY(block, fragmentHeight, pageHeight); + fragment.y = physicalY - bandOrigin; + } } } diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts index 0a39a3d8dc..c499754cfd 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, @@ -243,6 +244,75 @@ 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)); + }); + + 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', () => { + 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); + }); + + 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 672a866dc2..a43af8505e 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -57,6 +57,15 @@ 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') { + const serialized = stableSerializeEvidenceValue(value); + return `h:${serialized.length}:${hashString(2166136261, serialized).toString(36)}`; + } + return String(value); +}; + const getTrackedChangeLayers = (run: TextRun): TrackedChangeMeta[] => { if (Array.isArray(run.trackedChanges) && run.trackedChanges.length > 0) { return run.trackedChanges; @@ -272,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') { @@ -290,7 +299,7 @@ const stableSerializeEvidenceValue = (value: unknown): string => { .join(',')}}`; } return JSON.stringify(String(value)); -}; +} /** * Stable source/evidence metadata signature for paint cache invalidation. @@ -478,7 +487,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, @@ -510,10 +519,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('|'); } 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..fb1dddcdbe 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,168 @@ 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, + 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('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, + 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'); @@ -256,6 +425,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) => ({ @@ -269,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 f8220bf8a3..dd262908e7 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,7 +39,10 @@ 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 RIGHT_LEGEND_WIDTH = 110; const VALUE_TICK_COUNT = 5; +const DATA_LABEL_PADDING = 4; // ============================================================================ // Public API @@ -58,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'); @@ -135,6 +141,7 @@ export function formatTickValue(value: number): string { } type BarChartLayout = { + padding: typeof CHART_PADDING; plotWidth: number; plotHeight: number; groupWidth: number; @@ -146,6 +153,28 @@ type BarChartLayout = { maxValue: number; }; +type HorizontalBarChartLayout = { + padding: typeof HORIZONTAL_CHART_PADDING; + plotLeft: number; + plotTop: number; + plotWidth: number; + plotHeight: number; + groupHeight: number; + barHeight: number; + barGap: number; + baselineX: number; + valueRange: number; + minValue: number; + 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. @@ -165,7 +194,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) }), @@ -176,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); @@ -193,13 +234,71 @@ 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 { padding, plotWidth, plotHeight, groupWidth, barWidth, barGap, baselineY, valueRange, minValue, maxValue }; +} + +function computeHorizontalBarLayout( + width: number, + height: number, + series: ChartSeriesData[], + 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; + const plotTop = padding.top; + const plotWidth = Math.max(1, width - padding.left - padding.right); + const plotHeight = Math.max(1, height - padding.top - padding.bottom); - return { plotWidth, plotHeight, groupWidth, barWidth, barGap, baselineY, valueRange, minValue, maxValue }; + 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, (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); + + return { + plotLeft, + plotTop, + plotWidth, + plotHeight, + groupHeight, + barHeight, + barGap, + baselineX, + valueRange, + minValue, + maxValue, + padding, + }; +} + +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; + const { padding, groupWidth, barGap, barWidth, baselineY, valueRange, plotHeight } = layout; for (let si = 0; si < series.length; si++) { const s = series[si]!; @@ -208,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'); @@ -218,28 +317,181 @@ 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 placement = resolveVerticalBarDataLabelPlacement(s.dataLabels.position, value, x, y, barWidth, barHeight); + const label = doc.createElementNS(SVG_NS, 'text'); + 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', placement.fill); + label.setAttribute('font-family', FONT_FAMILY); + label.textContent = formatDataLabel(value, s.dataLabels.numberFormat); + svg.appendChild(label); + } + } + } +} + +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 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, + 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 placement = resolveHorizontalBarDataLabelPlacement( + s.dataLabels.position, + value, + x, + y, + barWidth, + barHeight, + ); + const label = doc.createElementNS(SVG_NS, 'text'); + 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', placement.fill); + 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; + 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'); @@ -253,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); @@ -271,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)); @@ -290,22 +548,92 @@ 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); } } } -function renderLegend(doc: Document, svg: SVGSVGElement, series: ChartSeriesData[], height: number): void { - const legendY = height - 12; - let legendX = CHART_PADDING.left; +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', AXIS_COLOR); + gridLine.setAttribute('stroke-width', '0.5'); + svg.appendChild(gridLine); + } + } +} + +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]!; @@ -328,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; + } } } @@ -669,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); @@ -732,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); @@ -868,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); @@ -1113,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); @@ -1175,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); @@ -1310,13 +1642,30 @@ 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 isHorizontalBar = chart.chartType === 'barChart' && chart.barDirection === 'bar'; const categoryLabels = categories.length; - const valueTicks = (VALUE_TICK_COUNT + 1) * 2; // labels + grid lines + 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 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 + horizontalCategoryLabels + dataLabels + valueLabels + gridLines + legend; + } + + const axes = 2; + const valueTicks = VALUE_TICK_COUNT + 1; + const gridLines = chart.valueAxis?.majorGridlines === true ? VALUE_TICK_COUNT - 1 : 0; + return bars + axes + categoryLabels + valueTicks + gridLines + dataLabels + legend; } function renderBarChart( @@ -1332,7 +1681,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,15 +1692,41 @@ function renderBarChart( svg.setAttribute('height', '100%'); svg.style.display = 'block'; - const layout = computeBarLayout(width, height, series); + 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, !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 (hasLegend) { + renderLegend(doc, svg, series, width, height, chart.legendPosition); + } + + if (truncated) { + renderTruncationIndicator(doc, svg, width); + } + + container.appendChild(svg); + return container; + } + + 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/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts b/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts index 703ed1de13..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 @@ -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; @@ -101,6 +101,199 @@ 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('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 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 = { + 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('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', + 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; + 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'); + }); + + 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; @@ -218,6 +411,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 }; @@ -1064,8 +1294,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 12d558067f..36bf49fcda 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, @@ -64,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'; @@ -2396,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); @@ -2431,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 @@ -3196,7 +3203,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'); @@ -3219,6 +3226,11 @@ export class DomPainter { if (resolvedSvgMarkup) { const svgElement = this.parseSafeSvg(resolvedSvgMarkup); if (svgElement) { + if (!customGeomSvg && (isConnectorPresetShape(block.shapeKind) || this.isLineLikeShape(block.shapeKind))) { + applyNonScalingStrokeToConnector(svgElement); + } else { + this.expandSvgViewBoxForCenteredStroke(svgElement); + } svgElement.setAttribute('width', '100%'); svgElement.setAttribute('height', '100%'); svgElement.style.display = 'block'; @@ -3230,6 +3242,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); } } @@ -3286,6 +3300,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)'; @@ -3302,6 +3320,10 @@ export class DomPainter { } } + private isLineLikeShape(shapeKind?: string | null): boolean { + return shapeKind === 'line' || shapeKind === 'straightConnector1'; + } + private hasShapeTextContent(textContent?: ShapeTextContent): textContent is ShapeTextContent { return Array.isArray(textContent?.parts) && textContent.parts.length > 0; } @@ -3730,6 +3752,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; @@ -3740,12 +3764,27 @@ 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 ` + `; } + if (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: () => ({ @@ -3762,6 +3801,84 @@ 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, + height: number, + ): string | null { + const stroke = + block.strokeColor === null ? 'none' : typeof block.strokeColor === 'string' ? block.strokeColor : '#000000'; + const strokeWidth = block.strokeWidth ?? 1; + return createConnectorPresetSvg({ + kind: block.shapeKind, + strokeColor: stroke, + strokeWidth, + width, + height, + }); + } + /** * 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 @@ -3897,31 +4014,15 @@ export class DomPainter { const defs = this.ensureSvgDefs(svgElement); const baseId = this.sanitizeSvgId(`sd-line-${block.id}`); - if (lineEnds.tail) { - const id = `${baseId}-tail`; - this.appendLineEndMarker( - defs, - id, - lineEnds.tail, - strokeColor, - strokeWidth, - true, - block.effectExtent ?? undefined, - ); + if (lineEnds.head) { + const id = `${baseId}-head`; + this.appendLineEndMarker(defs, id, lineEnds.head, strokeColor, strokeWidth, true); target.setAttribute('marker-start', `url(#${id})`); } - if (lineEnds.head) { - const id = `${baseId}-head`; - this.appendLineEndMarker( - defs, - id, - lineEnds.head, - strokeColor, - strokeWidth, - false, - block.effectExtent ?? undefined, - ); + if (lineEnds.tail) { + const id = `${baseId}-tail`; + this.appendLineEndMarker(defs, id, lineEnds.tail, strokeColor, strokeWidth, false); target.setAttribute('marker-end', `url(#${id})`); } } @@ -4076,62 +4177,8 @@ export class DomPainter { strokeColor: string, _strokeWidth: number, isStart: boolean, - effectExtent?: EffectExtent, ): 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 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'); - 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 { @@ -4268,7 +4315,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/layout-engine/painters/dom/src/table/renderTableCell.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts index 57f14ba8ac..60e535d6e7 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', @@ -652,14 +652,13 @@ describe('renderTableCell', () => { const { cellElement } = renderTableCell({ ...createBaseDeps(), x: 40, - tableIndent: 20, cellMeasure, cell, }); 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..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,8 +1123,10 @@ 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; + // 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; const behindDoc = 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/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/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/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/chart-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/chart-helpers.js index 042322f893..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 @@ -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,21 +166,25 @@ 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'); const legendPosition = parseLegendPosition(chart); const styleId = parseStyleId(chartSpace); + const chartAreaBorder = parseChartAreaBorder(chartSpace); return { chartType, ...(subType && { subType }), ...(barDirection && { barDirection }), + ...(gapWidth != null && { gapWidth }), series, ...(categoryAxis && { categoryAxis }), ...(valueAxis && { valueAxis }), ...(legendPosition && { legendPosition }), ...(styleId != null && { styleId }), + ...(chartAreaBorder != null && { chartAreaBorder }), }; } @@ -228,13 +238,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 +263,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 +289,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 +417,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; } @@ -396,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 c0c0935183..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); @@ -302,6 +313,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(); }); 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..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. @@ -1010,7 +998,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 +1726,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..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,6 +214,34 @@ function extractColorFromElement(element) { return null; } +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 +453,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 +480,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 = { 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..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 @@ -1,6 +1,16 @@ // @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, + createConnectorPresetSvg, + isConnectorPresetShape, +} from '@superdoc/preset-geometry/connectors'; +import { + createGradient, + createLineEndMarker, + createPictureFillPattern, + createTextElement, +} from '../shared/svg-utils.js'; import { OOXML_Z_INDEX_BASE } from '@extensions/shared/constants.js'; export class ShapeGroupView { @@ -255,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') { @@ -391,6 +403,48 @@ export class ShapeGroupView { return g; } + if (isConnectorPresetShape(shapeKind)) { + 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); + } + if (connectorSvg) { + g.appendChild(connectorSvg); + } + } + + 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({ @@ -484,6 +538,13 @@ export class ShapeGroupView { lineEndsApplied = true; } + if ( + isConnectorPresetShape(shapeKind) && + (clonedChild.tagName === 'path' || clonedChild.tagName === 'line' || clonedChild.tagName === 'polyline') + ) { + applyNonScalingStrokeToConnectorTarget(clonedChild); + } + g.appendChild(clonedChild); }); } @@ -544,92 +605,17 @@ 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); - target.setAttribute('marker-start', `url(#${id})`); - } - if (lineEnds.head) { const id = `${markerBase}-head`; - this.createLineEndMarker(defs, id, lineEnds.head, strokeColor, strokeWidth, false, null); - target.setAttribute('marker-end', `url(#${id})`); + createLineEndMarker(defs, id, lineEnds.head, strokeColor, strokeWidth, true); + target.setAttribute('marker-start', `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 (tail) or end marker (head) - * @param {Object|null} effectExtent - Effect extent for sizing, or null - */ - createLineEndMarker(defs, id, lineEnd, strokeColor, _strokeWidth, isStart, effectExtent) { - 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 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'); - 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; + if (lineEnds.tail) { + const id = `${markerBase}-tail`; + createLineEndMarker(defs, id, lineEnds.tail, strokeColor, strokeWidth, false); + target.setAttribute('marker-end', `url(#${id})`); } - - 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) { 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..3f0357710a --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.test.js @@ -0,0 +1,93 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it, vi } from 'vitest'; +import { ShapeGroupView } from './ShapeGroupView.js'; + +function createView(kind, attrs = {}) { + 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, + ...attrs, + }, + }, + ], + }, + }, + editor: { view: {} }, + getPos: vi.fn(() => 0), + decorations: [], + innerDecorations: [], + extension: {}, + htmlAttributes: {}, + }); +} + +describe('ShapeGroupView connector rendering', () => { + 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'], + ])('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('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', () => { + 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 fd1676a887..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,4 +1,40 @@ import { formatChapterPageNumberText, formatPageNumber } from '@superdoc/contracts'; +import { + createLineEndMarker as createLineEndMarkerWithDocument, + createLineEndShape as createLineEndShapeWithDocument, +} from '@superdoc/preset-geometry/connectors'; + +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); +} + +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('patternContentUnits', '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 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..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,9 +2,63 @@ * @vitest-environment jsdom */ import { describe, it, expect, beforeEach } from 'vitest'; -import { createTextElement, createGradient, generateTransforms } 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', () => { + 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'], + ['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 8aa2ad7c6b..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,6 +14,8 @@ import { applyGradientToSVG, applyAlphaToSVG, generateTransforms, + createLineEndMarker, + createPictureFillPattern, } from '../shared/svg-utils.js'; export class VectorShapeView { @@ -315,6 +323,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 @@ -337,6 +347,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; @@ -388,11 +400,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: @@ -404,6 +418,23 @@ export class VectorShapeView { tempDiv.innerHTML = svgTemplate; const tempSvg = tempDiv.querySelector('svg'); if (tempSvg) { + 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()); @@ -430,6 +461,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; @@ -438,7 +474,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; @@ -456,92 +492,17 @@ 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); - target.setAttribute('marker-start', `url(#${id})`); - } - if (lineEnds.head) { const id = `${idBase}-head`; - this.createLineEndMarker(defs, id, lineEnds.head, strokeColor, strokeWidth, false, attrs.effectExtent); - target.setAttribute('marker-end', `url(#${id})`); + createLineEndMarker(defs, id, lineEnds.head, strokeColor, strokeWidth, true); + target.setAttribute('marker-start', `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 (tail) or end marker (head) - * @param {Object|null} effectExtent - Effect extent for sizing, or null - */ - createLineEndMarker(defs, id, lineEnd, strokeColor, _strokeWidth, isStart, effectExtent) { - 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 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'); - 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; + if (lineEnds.tail) { + const id = `${idBase}-tail`; + createLineEndMarker(defs, id, lineEnds.tail, strokeColor, strokeWidth, false); + target.setAttribute('marker-end', `url(#${id})`); } - - 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) { @@ -550,6 +511,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') { @@ -557,6 +522,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 1f9c123069..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 @@ -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; @@ -339,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: { @@ -409,6 +446,111 @@ describe('VectorShapeView', () => { expect(svg.getAttribute('width')).toBe('200'); expect(svg.getAttribute('height')).toBe('50'); }); + + it('renders connector presets in target coordinates with non-scaling strokes and line ends', () => { + const connectorNode = { + attrs: { + kind: 'bentConnector3', + width: 427, + height: 28, + 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 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'); + }); + + 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', () => {