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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions docs/architecture/graphics-rendering-matrix.md

Large diffs are not rendered by default.

11 changes: 7 additions & 4 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -952,7 +952,7 @@ export type ImageBlock = {
display?: 'inline' | 'block';
padding?: BoxSpacing;
margin?: BoxSpacing;
anchor?: ImageAnchor;
anchor?: GraphicPlacement;
wrap?: ImageWrap;
/** Stacking order from OOXML relativeHeight (same formula as editor: Math.max(0, relativeHeight - OOXML_Z_INDEX_BASE)) */
zIndex?: number;
Expand Down Expand Up @@ -1173,7 +1173,7 @@ export type DrawingBlockBase = {
drawingKind: DrawingKind;
margin?: BoxSpacing;
padding?: BoxSpacing;
anchor?: ImageAnchor;
anchor?: GraphicPlacement;
wrap?: ImageWrap;
zIndex?: number;
drawingContentId?: string;
Expand Down Expand Up @@ -1421,8 +1421,8 @@ export type ColumnBreakBlock = {
attrs?: Record<string, unknown>;
};

/** Positioning for anchored images (offsets in CSS px). */
export type ImageAnchor = {
/** Shared positioning for anchored graphics: images, shapes, shape groups, textboxes, and charts. */
export type GraphicPlacement = {
isAnchored?: boolean;
hRelativeFrom?: 'column' | 'page' | 'margin';
vRelativeFrom?: 'paragraph' | 'page' | 'margin';
Expand All @@ -1435,6 +1435,9 @@ export type ImageAnchor = {
margin?: BoxSpacing | undefined;
};

/** @deprecated Use GraphicPlacement for image, shape, and chart placement data. */
export type ImageAnchor = GraphicPlacement;

/** Text wrapping for floating images (distances in px). */
export type ImageWrap = {
type: 'None' | 'Square' | 'Tight' | 'Through' | 'TopAndBottom' | 'Inline';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,62 @@ describe('chartNodeToDrawingBlock', () => {
expect(result.anchor!.offsetV).toBe(36);
});

it('keeps offset-only chart anchors offset-only', () => {
const node = makeNode({
chartData: { chartType: 'barChart', series: [] },
isAnchor: true,
anchorData: {
hRelativeFrom: 'column',
vRelativeFrom: 'paragraph',
offsetH: 24,
offsetV: 36,
},
});

const result = chartNodeToDrawingBlock(node, mockBlockIdGenerator, mockPositionMap);

expect(result.anchor).toEqual({
isAnchored: true,
hRelativeFrom: 'column',
vRelativeFrom: 'paragraph',
offsetH: 24,
offsetV: 36,
});
});

it('preserves authored chart alignment and behindDoc fallback', () => {
const node = makeNode({
chartData: { chartType: 'barChart', series: [] },
isAnchor: true,
anchorData: {
hRelativeFrom: 'margin',
vRelativeFrom: 'page',
alignH: 'center',
alignV: 'bottom',
offsetH: 12,
offsetV: 18,
},
originalAttributes: {
behindDoc: '1',
relativeHeight: 251660288,
},
});

const result = chartNodeToDrawingBlock(node, mockBlockIdGenerator, mockPositionMap);

expect(result.anchor).toEqual({
isAnchored: true,
hRelativeFrom: 'margin',
vRelativeFrom: 'page',
alignH: 'center',
alignV: 'bottom',
offsetH: 12,
offsetV: 18,
behindDoc: true,
});
expect(result.zIndex).toBe(0);
});

it('produces no anchor when anchorData is absent and isAnchor is false', () => {
const node = makeNode({ chartData: { chartType: 'barChart', series: [] } });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Converts ProseMirror chart nodes to DrawingBlocks with drawingKind: 'chart'.
*/

import type { ChartDrawing, DrawingGeometry, BoxSpacing, ImageAnchor, SourceAnchor } from '@superdoc/contracts';
import type { ChartDrawing, DrawingGeometry, BoxSpacing, SourceAnchor } from '@superdoc/contracts';
import type { PMNode, NodeHandlerContext, BlockIdGenerator, PositionMap } from '../types.js';
import {
pickNumber,
Expand All @@ -16,16 +16,14 @@ import {
normalizeZIndex,
resolveFloatingZIndex,
} from '../utilities.js';
import { normalizeGraphicAnchor } from '../graphic-placement.js';

// ============================================================================
// Constants
// ============================================================================

const WRAP_TYPES = new Set(['None', 'Square', 'Tight', 'Through', 'TopAndBottom', 'Inline']);
const WRAP_TEXT_VALUES = new Set(['bothSides', 'left', 'right', 'largest']);
const H_RELATIVE_VALUES = new Set(['column', 'page', 'margin']);
const V_RELATIVE_VALUES = new Set(['paragraph', 'page', 'margin']);

// ============================================================================
// Helpers
// ============================================================================
Expand Down Expand Up @@ -61,47 +59,6 @@ const normalizeWrap = (value: unknown): ChartDrawing['wrap'] | undefined => {
return wrap;
};

const normalizeAnchor = (
value: unknown,
attrs: Record<string, unknown>,
wrapBehindDoc?: boolean,
): ImageAnchor | undefined => {
const raw = isPlainObject(value) ? value : undefined;
const marginOffset = isPlainObject(attrs.marginOffset) ? attrs.marginOffset : undefined;
const simplePos = isPlainObject(attrs.simplePos) ? attrs.simplePos : undefined;
const isAnchored = attrs.isAnchor === true || Boolean(raw);

const anchor: ImageAnchor = {};
if (isAnchored) anchor.isAnchored = true;

const hRelative =
typeof raw?.hRelativeFrom === 'string' && H_RELATIVE_VALUES.has(raw.hRelativeFrom) ? raw.hRelativeFrom : undefined;
if (hRelative) anchor.hRelativeFrom = hRelative as ImageAnchor['hRelativeFrom'];

const vRelative =
typeof raw?.vRelativeFrom === 'string' && V_RELATIVE_VALUES.has(raw.vRelativeFrom) ? raw.vRelativeFrom : undefined;
if (vRelative) anchor.vRelativeFrom = vRelative as ImageAnchor['vRelativeFrom'];

const offsetH = pickNumber(marginOffset?.horizontal ?? marginOffset?.left ?? raw?.offsetH ?? simplePos?.x);
if (offsetH != null) anchor.offsetH = offsetH;

const offsetV = pickNumber(marginOffset?.top ?? marginOffset?.vertical ?? raw?.offsetV ?? simplePos?.y);
if (offsetV != null) anchor.offsetV = offsetV;

const behindDoc = toBoolean(raw?.behindDoc ?? wrapBehindDoc);
if (behindDoc != null) anchor.behindDoc = behindDoc;

const hasData =
anchor.isAnchored ||
anchor.hRelativeFrom != null ||
anchor.vRelativeFrom != null ||
anchor.offsetH != null ||
anchor.offsetV != null ||
anchor.behindDoc != null;

return hasData ? anchor : undefined;
};

// ============================================================================
// Chart Converter
// ============================================================================
Expand Down Expand Up @@ -146,7 +103,11 @@ export function chartNodeToDrawingBlock(

const normalizedWrap = normalizeWrap(rawAttrs.wrap);
const sourceAnchor = isPlainObject(rawAttrs.sourceAnchor) ? (rawAttrs.sourceAnchor as SourceAnchor) : undefined;
const anchor = normalizeAnchor(rawAttrs.anchorData, rawAttrs, normalizedWrap?.behindDoc);
const anchor = normalizeGraphicAnchor({
anchorData: rawAttrs.anchorData,
attrs: rawAttrs,
wrapBehindDoc: normalizedWrap?.behindDoc,
});

const pos = positions.get(node);
const attrsWithPm: Record<string, unknown> = { ...rawAttrs };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Handles conversion of ProseMirror image nodes to ImageBlocks
*/

import type { ImageBlock, BoxSpacing, ImageAnchor, SourceAnchor } from '@superdoc/contracts';
import type { ImageBlock, BoxSpacing, SourceAnchor } from '@superdoc/contracts';
import type { PMNode, BlockIdGenerator, PositionMap, NodeHandlerContext, TrackedChangesConfig } from '../types.js';
import { collectTrackedChangeFromMarks } from '../marks/index.js';
import { shouldHideTrackedNode, annotateBlockWithTrackedChange } from '../tracked-changes.js';
Expand All @@ -15,19 +15,16 @@ import {
resolveFloatingZIndex,
readImageHyperlink,
mergeWrapDistancesFromPadding,
toBoolean,
} from '../utilities.js';
import { normalizeGraphicAnchor } from '../graphic-placement.js';

// ============================================================================
// Constants
// ============================================================================

const WRAP_TYPES = new Set(['None', 'Square', 'Tight', 'Through', 'TopAndBottom', 'Inline']);
const WRAP_TEXT_VALUES = new Set(['bothSides', 'left', 'right', 'largest']);
const H_RELATIVE_VALUES = new Set(['column', 'page', 'margin']);
const V_RELATIVE_VALUES = new Set(['paragraph', 'page', 'margin']);
const H_ALIGN_VALUES = new Set(['left', 'center', 'right']);
const V_ALIGN_VALUES = new Set(['top', 'center', 'bottom']);

// ============================================================================
// Helper Functions - Type Checking
// ============================================================================
Expand Down Expand Up @@ -96,21 +93,6 @@ const normalizePolygon = (value: unknown): number[][] | undefined => {
return polygon.length > 0 ? polygon : undefined;
};

const toBoolean = (value: unknown): boolean | undefined => {
if (value === undefined || value === null) return undefined;
if (typeof value === 'boolean') return value;
if (typeof value === 'number') {
if (value === 1) return true;
if (value === 0) return false;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === '1' || normalized === 'true') return true;
if (normalized === '0' || normalized === 'false') return false;
}
return undefined;
};

const normalizeWrap = (value: unknown): ImageBlock['wrap'] | undefined => {
if (!isPlainObject(value)) {
return undefined;
Expand Down Expand Up @@ -153,66 +135,6 @@ const normalizeWrap = (value: unknown): ImageBlock['wrap'] | undefined => {
return wrap;
};

const normalizeAnchorRelative = (value: unknown, allowed: Set<string>): string | undefined => {
if (typeof value !== 'string') return undefined;
return allowed.has(value) ? value : undefined;
};

const normalizeAnchorAlign = (value: unknown, allowed: Set<string>): string | undefined => {
if (typeof value !== 'string') return undefined;
return allowed.has(value) ? value : undefined;
};

const normalizeAnchorData = (
value: unknown,
attrs: Record<string, unknown>,
wrapBehindDoc?: boolean,
): ImageAnchor | undefined => {
const raw = isPlainObject(value) ? value : undefined;
const marginOffset = isPlainObject(attrs.marginOffset) ? attrs.marginOffset : undefined;
const simplePos = isPlainObject(attrs.simplePos) ? attrs.simplePos : undefined;
const originalAttrs = isPlainObject(attrs.originalAttributes) ? attrs.originalAttributes : undefined;
const isAnchored = attrs.isAnchor === true || Boolean(raw);

const anchor: ImageAnchor = {};
if (isAnchored) {
anchor.isAnchored = true;
}

const hRelative = normalizeAnchorRelative(raw?.hRelativeFrom, H_RELATIVE_VALUES);
if (hRelative) anchor.hRelativeFrom = hRelative as ImageAnchor['hRelativeFrom'];

const vRelative = normalizeAnchorRelative(raw?.vRelativeFrom, V_RELATIVE_VALUES);
if (vRelative) anchor.vRelativeFrom = vRelative as ImageAnchor['vRelativeFrom'];

const alignH = normalizeAnchorAlign(raw?.alignH, H_ALIGN_VALUES);
if (alignH) anchor.alignH = alignH as ImageAnchor['alignH'];

const alignV = normalizeAnchorAlign(raw?.alignV, V_ALIGN_VALUES);
if (alignV) anchor.alignV = alignV as ImageAnchor['alignV'];

const offsetH = pickNumber(marginOffset?.horizontal ?? marginOffset?.left ?? raw?.offsetH ?? simplePos?.x);
if (offsetH != null) anchor.offsetH = offsetH;

const offsetV = pickNumber(marginOffset?.top ?? marginOffset?.vertical ?? raw?.offsetV ?? simplePos?.y);
if (offsetV != null) anchor.offsetV = offsetV;

const behindDoc = toBoolean(raw?.behindDoc ?? wrapBehindDoc ?? originalAttrs?.behindDoc);
if (behindDoc != null) anchor.behindDoc = behindDoc;

const hasData =
anchor.isAnchored ||
anchor.hRelativeFrom != null ||
anchor.vRelativeFrom != null ||
anchor.alignH != null ||
anchor.alignV != null ||
anchor.offsetH != null ||
anchor.offsetV != null ||
anchor.behindDoc != null;

return hasData ? anchor : undefined;
};

// ============================================================================
// Image Converter Function
// ============================================================================
Expand Down Expand Up @@ -258,7 +180,11 @@ export function imageNodeToBlock(
if (normalizedWrap) {
mergeWrapDistancesFromPadding(normalizedWrap, toBoxSpacing(attrs.padding as Record<string, unknown> | undefined));
}
let anchor = normalizeAnchorData(attrs.anchorData, attrs, normalizedWrap?.behindDoc);
let anchor = normalizeGraphicAnchor({
anchorData: attrs.anchorData,
attrs,
wrapBehindDoc: normalizedWrap?.behindDoc,
});
if (!anchor && normalizedWrap) {
anchor = { isAnchored: true };
if (normalizedWrap.behindDoc != null) {
Expand Down
Loading
Loading