Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -962,5 +962,5 @@
}
],
"marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}",
"sourceHash": "278f3d13c4cb49be6d084639559a5de63cd623606bc046c4abea69815fcbbe1c"
"sourceHash": "42294181d9125c3dfb3525be01eb6c645c3a18d511bd95d678d6661920490721"
}
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,18 @@ describe('getDocumentApiCapabilities', () => {
underline: { create: vi.fn(() => ({ type: 'underline' })) },
strike: { create: vi.fn(() => ({ type: 'strike' })) },
highlight: { create: vi.fn(() => ({ type: 'highlight' })) },
textStyle: { create: vi.fn(() => ({ type: 'textStyle' })) },
textStyle: {
create: vi.fn(() => ({ type: 'textStyle' })),
attrs: {
color: { default: null },
fontSize: { default: null },
fontFamily: { default: null },
letterSpacing: { default: null },
vertAlign: { default: null },
position: { default: null },
textTransform: { default: null },
},
},
[TrackFormatMarkName]: { create: vi.fn(() => ({ type: TrackFormatMarkName })) },
...overrides.marks,
},
Expand Down Expand Up @@ -401,6 +412,33 @@ describe('getDocumentApiCapabilities', () => {
expect(capabilities.format.supportedInlineProperties.bold.available).toBe(true);
});

it('reports a textStyle-backed property as unavailable when its attr is missing from textStyle (SD-2074)', () => {
const capabilities = getDocumentApiCapabilities(
makeFormatEditor({
marks: {
textStyle: {
create: vi.fn(() => ({ type: 'textStyle' })),
attrs: {
color: { default: null },
fontSize: { default: null },
fontFamily: { default: null },
vertAlign: { default: null },
position: { default: null },
textTransform: { default: null },
// letterSpacing deliberately omitted — simulates missing LetterSpacing extension
},
},
},
}),
);
expect(capabilities.format.supportedInlineProperties.letterSpacing.available).toBe(false);
expect(capabilities.operations['format.letterSpacing'].available).toBe(false);
expect(capabilities.operations['format.letterSpacing'].reasons).toContain('OPERATION_UNAVAILABLE');
// Other textStyle-backed properties remain available
expect(capabilities.format.supportedInlineProperties.color.available).toBe(true);
expect(capabilities.format.supportedInlineProperties.fontSize.available).toBe(true);
});

it('reports run-attribute properties as unavailable when the run node is missing', () => {
const capabilities = getDocumentApiCapabilities(makeFormatEditor({ nodes: { run: undefined } }));
expect(capabilities.format.supportedInlineProperties.rFonts.available).toBe(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -340,8 +340,17 @@ function getInlineAliasKey(operationId: OperationId): InlineRunPatchKey | undefi
function isInlinePropertyAvailable(editor: Editor, property: InlinePropertyRegistryEntry): boolean {
if (property.storage === 'mark') {
if (property.carrier.storage !== 'mark') return false;
const markName = property.carrier.markName === 'textStyle' ? 'textStyle' : property.carrier.markName;
return hasMarkCapability(editor, markName);
const markName = property.carrier.markName;
if (!hasMarkCapability(editor, markName)) return false;
if (markName === 'textStyle' && property.carrier.textStyleAttr) {
const textStyleMark = editor.schema.marks.textStyle as {
spec?: { attrs?: Record<string, unknown> };
attrs?: Record<string, unknown>;
};
const markAttrs = textStyleMark?.spec?.attrs ?? textStyleMark?.attrs;
if (!markAttrs || !Object.prototype.hasOwnProperty.call(markAttrs, property.carrier.textStyleAttr)) return false;
}
return true;
}
return Boolean(editor.schema?.nodes?.run);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
formatUnderlineAdapter,
formatStrikethroughAdapter,
} from './format-adapter.js';
import { styleApplyWrapper } from './plan-engine/plan-wrappers.js';

type NodeOptions = {
attrs?: Record<string, unknown>;
Expand Down Expand Up @@ -386,3 +387,43 @@ describe('formatStrikethroughAdapter', () => {
expect(tr.setMeta).toHaveBeenCalledWith('forceTrackChanges', true);
});
});

// ---------------------------------------------------------------------------
// SD-2074 regression: format.letterSpacing false-success when textStyle mark
// exists but the letterSpacing attr is not registered (LetterSpacing extension
// absent).
// ---------------------------------------------------------------------------

describe('styleApplyWrapper — textStyle attr gating (SD-2074)', () => {
it('throws CAPABILITY_UNAVAILABLE for letterSpacing when its attr is missing from textStyle', () => {
const { editor } = makeEditor();
// Add a textStyle mark with attrs that do NOT include letterSpacing,
// simulating an editor where the LetterSpacing extension is not loaded.
(editor.schema as Record<string, unknown>).marks = {
...editor.schema?.marks,
textStyle: {
create: vi.fn(() => ({ type: 'textStyle' })),
attrs: {
color: { default: null },
fontSize: { default: null },
fontFamily: { default: null },
vertAlign: { default: null },
position: { default: null },
textTransform: { default: null },
// letterSpacing deliberately omitted
},
},
};

expect(() =>
styleApplyWrapper(
editor,
{
target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } },
inline: { letterSpacing: 10 },
},
{ changeMode: 'direct' },
),
).toThrow(/requires the "letterSpacing" attribute on the textStyle mark/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,92 @@ function createTestMark(name: string, attrs: Record<string, unknown> = {}) {
};
}

function makeTextStylePlanEditor(textStyleAttrNames: string[]): {
editor: Editor;
tr: {
addMark: ReturnType<typeof vi.fn>;
removeMark: ReturnType<typeof vi.fn>;
setMeta: ReturnType<typeof vi.fn>;
replaceWith: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
insert: ReturnType<typeof vi.fn>;
mapping: { map: (pos: number) => number };
docChanged: boolean;
doc: {
nodesBetween: ReturnType<typeof vi.fn>;
nodeAt: ReturnType<typeof vi.fn>;
textBetween: ReturnType<typeof vi.fn>;
textContent: string;
resolve: ReturnType<typeof vi.fn>;
};
};
dispatch: ReturnType<typeof vi.fn>;
} {
const textStyleAttrs = Object.fromEntries(textStyleAttrNames.map((name) => [name, { default: null }]));
const textStyleCreate = vi.fn((input: Record<string, unknown> = {}) =>
createTestMark(
'textStyle',
Object.fromEntries(
Object.entries(input).filter(([key]) => Object.prototype.hasOwnProperty.call(textStyleAttrs, key)),
),
),
);

const textNode = { isText: true, nodeSize: 5, marks: [] as unknown[] };
const doc = {
nodesBetween: vi.fn((_from: number, _to: number, callback: (node: typeof textNode, pos: number) => void) => {
callback(textNode, 1);
}),
nodeAt: vi.fn(() => null),
textBetween: vi.fn(() => 'Hello'),
textContent: 'Hello',
resolve: vi.fn(() => ({ marks: () => [] })),
};

const tr = {
replaceWith: vi.fn(),
delete: vi.fn(),
insert: vi.fn(),
addMark: vi.fn(),
removeMark: vi.fn(),
setMeta: vi.fn(),
mapping: { map: (pos: number) => pos },
docChanged: true,
doc,
};
tr.replaceWith.mockReturnValue(tr);
tr.delete.mockReturnValue(tr);
tr.insert.mockReturnValue(tr);
tr.addMark.mockReturnValue(tr);
tr.removeMark.mockReturnValue(tr);
tr.setMeta.mockReturnValue(tr);

const dispatch = vi.fn();
const textStyle = {
spec: { attrs: textStyleAttrs },
attrs: textStyleAttrs,
create: textStyleCreate,
};

const editor = {
state: {
doc,
tr,
schema: {
marks: { textStyle },
nodes: {},
},
},
schema: {
marks: { textStyle },
nodes: {},
},
dispatch,
} as unknown as Editor;

return { editor, tr, dispatch };
}

describe('executeTextInsert: setMarks tri-state directives', () => {
it('maps on/off/clear to canonical mark emission', () => {
const boldCreate = vi.fn((attrs?: Record<string, unknown> | null) =>
Expand Down Expand Up @@ -1998,3 +2084,41 @@ describe('executeSpanStyleApply: collapsed-range no-op guard', () => {
expect(tr.removeMark).not.toHaveBeenCalled();
});
});

describe('executeCompiledPlan: format.apply textStyle attr gating', () => {
it('throws CAPABILITY_UNAVAILABLE for caps when textStyle lacks textTransform', () => {
const { editor, tr, dispatch } = makeTextStylePlanEditor([
'color',
'fontSize',
'fontFamily',
'letterSpacing',
'vertAlign',
'position',
]);

const step: StyleApplyStep = {
id: 'step-format-caps',
op: 'format.apply',
where: { by: 'target', target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } } as any,
args: { inline: { caps: true } },
};
const compiled: CompiledPlan = {
mutationSteps: [{ step, targets: [makeTarget({ stepId: step.id, op: step.op })] }],
assertSteps: [],
compiledRevision: '0',
};

expect(() => executeCompiledPlan(editor, compiled)).toThrow(PlanError);
try {
executeCompiledPlan(editor, compiled);
} catch (error) {
expect(error).toBeInstanceOf(PlanError);
const planErr = error as PlanError;
expect(planErr.code).toBe('CAPABILITY_UNAVAILABLE');
expect(planErr.message).toContain('textTransform');
}

expect(tr.addMark).not.toHaveBeenCalled();
expect(dispatch).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { InlineRunPatchKey } from '@superdoc/document-api';
import { INLINE_PROPERTY_BY_KEY } from '@superdoc/document-api';
import type { Editor } from '../../core/Editor.js';

export interface InlinePropertyGuardIssue {
code: 'CAPABILITY_UNAVAILABLE';
message: string;
details?: Record<string, unknown>;
}

function getSchemaMarks(editor: Editor): Record<string, unknown> {
return (editor.schema?.marks ?? editor.state.schema?.marks ?? {}) as Record<string, unknown>;
}

function getSchemaNodes(editor: Editor): Record<string, unknown> {
return (editor.state.schema?.nodes ?? editor.schema?.nodes ?? {}) as Record<string, unknown>;
}

function getMarkAttrs(markType: unknown): Record<string, unknown> | undefined {
if (!markType || typeof markType !== 'object') return undefined;

const specAttrs = (markType as { spec?: { attrs?: Record<string, unknown> } }).spec?.attrs;
if (specAttrs && typeof specAttrs === 'object') return specAttrs;

const attrs = (markType as { attrs?: Record<string, unknown> }).attrs;
if (attrs && typeof attrs === 'object') return attrs;

return undefined;
}

export function getInlinePropertyCapabilityIssue(
editor: Editor,
keys: readonly InlineRunPatchKey[],
operationName = 'format.apply',
): InlinePropertyGuardIssue | undefined {
const schemaMarks = getSchemaMarks(editor);
const requiredTextStyleAttrs = new Set<string>();
let requiresRunNode = false;

for (const key of keys) {
const entry = INLINE_PROPERTY_BY_KEY[key];
if (!entry) continue;

if (entry.storage === 'mark') {
const carrier = entry.carrier;
if (carrier.storage !== 'mark') continue;

if (!schemaMarks[carrier.markName]) {
return {
code: 'CAPABILITY_UNAVAILABLE',
message: `${operationName} requires the "${carrier.markName}" mark.`,
details: { reason: 'missing_mark', markName: carrier.markName },
};
}

if (carrier.markName === 'textStyle' && carrier.textStyleAttr) {
requiredTextStyleAttrs.add(carrier.textStyleAttr);
}

continue;
}

requiresRunNode = true;
}

if (requiredTextStyleAttrs.size > 0) {
const markAttrs = getMarkAttrs(schemaMarks.textStyle);
for (const attr of requiredTextStyleAttrs) {
if (!markAttrs || !Object.prototype.hasOwnProperty.call(markAttrs, attr)) {
return {
code: 'CAPABILITY_UNAVAILABLE',
message: `${operationName} requires the "${attr}" attribute on the textStyle mark.`,
details: { reason: 'missing_mark_attribute', markName: 'textStyle', attribute: attr },
};
}
}
}

if (requiresRunNode && !getSchemaNodes(editor).run) {
return {
code: 'CAPABILITY_UNAVAILABLE',
message: `${operationName} requires a run node in the schema.`,
};
}

return undefined;
}

export function getTrackedInlinePropertySupportIssue(
keys: readonly InlineRunPatchKey[],
operationName = 'format.apply',
): InlinePropertyGuardIssue | undefined {
const unsupportedTrackedKeys = keys.filter((key) => INLINE_PROPERTY_BY_KEY[key]?.tracked === false);
if (unsupportedTrackedKeys.length === 0) return undefined;

return {
code: 'CAPABILITY_UNAVAILABLE',
message: `${operationName} tracked mode is not available for: ${unsupportedTrackedKeys.join(', ')}`,
details: { keys: unsupportedTrackedKeys, changeMode: 'tracked' },
};
}
Loading
Loading