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
103 changes: 103 additions & 0 deletions packages/layout-engine/style-engine/src/cascade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,109 @@ describe('cascade - combineRunProperties', () => {
bold: true,
});
});

// SD-2894: each `<w:rFonts>` script slot has both a concrete form (`w:ascii`,
// `w:hAnsi`, `w:eastAsia`, `w:cs`) and a theme form (`w:asciiTheme`,
// `w:hAnsiTheme`, `w:eastAsiaTheme`, `w:cstheme` — note `cstheme` lowercase).
// When a higher-priority source supplies one form for a slot, the cascade must
// drop the other form from lower-priority sources, or Word resolves the concrete
// name as an override and defeats per-script theme resolution.
//
// The original SD-2894 bug was that only the (ascii, asciiTheme) pair was
// dropping correctly; hAnsi/eastAsia/cs leaked through. These tests pin the
// full 4-slot dedup so the bug cannot regress for any single slot.
describe('SD-2894 four-slot theme/concrete dedup', () => {
it('drops concrete `ascii` from lower when higher supplies `asciiTheme`', () => {
const result = combineRunProperties([
{ fontFamily: { ascii: 'Calibri' } },
{ fontFamily: { asciiTheme: 'majorBidi' } },
]);
expect(result.fontFamily).toEqual({ asciiTheme: 'majorBidi' });
});

it('drops concrete `hAnsi` from lower when higher supplies `hAnsiTheme`', () => {
const result = combineRunProperties([
{ fontFamily: { hAnsi: 'Calibri' } },
{ fontFamily: { hAnsiTheme: 'majorHAnsi' } },
]);
expect(result.fontFamily).toEqual({ hAnsiTheme: 'majorHAnsi' });
});

it('drops concrete `eastAsia` from lower when higher supplies `eastAsiaTheme`', () => {
const result = combineRunProperties([
{ fontFamily: { eastAsia: 'SimSun' } },
{ fontFamily: { eastAsiaTheme: 'majorEastAsia' } },
]);
expect(result.fontFamily).toEqual({ eastAsiaTheme: 'majorEastAsia' });
});

it('drops concrete `cs` from lower when higher supplies `cstheme` (lowercase)', () => {
const result = combineRunProperties([
{ fontFamily: { cs: 'Arial' } },
{ fontFamily: { cstheme: 'majorBidi' } },
]);
expect(result.fontFamily).toEqual({ cstheme: 'majorBidi' });
});

it('drops theme `asciiTheme` from lower when higher supplies concrete `ascii`', () => {
const result = combineRunProperties([
{ fontFamily: { asciiTheme: 'majorBidi' } },
{ fontFamily: { ascii: 'Arial' } },
]);
expect(result.fontFamily).toEqual({ ascii: 'Arial' });
});

it('drops theme `hAnsiTheme` from lower when higher supplies concrete `hAnsi`', () => {
const result = combineRunProperties([
{ fontFamily: { hAnsiTheme: 'majorHAnsi' } },
{ fontFamily: { hAnsi: 'Arial' } },
]);
expect(result.fontFamily).toEqual({ hAnsi: 'Arial' });
});

it('drops theme `eastAsiaTheme` from lower when higher supplies concrete `eastAsia`', () => {
const result = combineRunProperties([
{ fontFamily: { eastAsiaTheme: 'majorEastAsia' } },
{ fontFamily: { eastAsia: 'Arial' } },
]);
expect(result.fontFamily).toEqual({ eastAsia: 'Arial' });
});

it('drops theme `cstheme` from lower when higher supplies concrete `cs`', () => {
const result = combineRunProperties([
{ fontFamily: { cstheme: 'majorBidi' } },
{ fontFamily: { cs: 'Arial' } },
]);
expect(result.fontFamily).toEqual({ cs: 'Arial' });
});

it('Athenaintelligence customer shape: all 4 concretes from defaults dropped by inline themes', () => {
// Mirrors the customer fixture: docDefaults supply concrete fonts (Arial),
// inline rPr supplies theme refs on ascii/hAnsi/cs (no eastAsiaTheme). The
// cascade must keep only the theme refs on those three slots; eastAsia
// concrete from defaults is independent.
const result = combineRunProperties([
{ fontFamily: { ascii: 'Arial', hAnsi: 'Arial', cs: 'Arial', eastAsia: 'Arial' } },
{ fontFamily: { asciiTheme: 'majorBidi', hAnsiTheme: 'majorBidi', cstheme: 'majorBidi' } },
]);
expect(result.fontFamily).toEqual({
asciiTheme: 'majorBidi',
hAnsiTheme: 'majorBidi',
cstheme: 'majorBidi',
eastAsia: 'Arial',
});
});

it('exports FONT_SLOT_THEME_PAIRS so callers (super-editor plugin) can stay in sync', async () => {
const { FONT_SLOT_THEME_PAIRS } = await import('./cascade.js');
expect(FONT_SLOT_THEME_PAIRS).toEqual([
['ascii', 'asciiTheme'],
['hAnsi', 'hAnsiTheme'],
['eastAsia', 'eastAsiaTheme'],
['cs', 'cstheme'],
]);
});
});
});

describe('cascade - combineIndentProperties', () => {
Expand Down
47 changes: 39 additions & 8 deletions packages/layout-engine/style-engine/src/cascade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,20 +104,51 @@ function isObject(item: unknown): item is PropertyObject {
* @param propertiesArray - Ordered list of run property objects.
* @returns Combined run property object.
*/
// SD-2894: `<w:rFonts>` exposes four independent script slots (ascii / hAnsi / eastAsia / cs).
// Each slot can be filled either by a concrete font name (`w:ascii="Calibri"`) or a theme
// reference (`w:asciiTheme="majorBidi"`). When a higher-priority style supplies one form for a
// slot, the other form from lower-priority sources is no longer applicable and must be
// dropped — otherwise the merged run carries both, and Word resolves the concrete name as an
// override that defeats per-script theme resolution (Latin body text mapped to `majorBidi`
// then renders as Calibri Light instead of Times New Roman for Hebrew/Arab).
//
// Pair `<slot>` with `<slot>Theme`, except for the cs slot whose theme attribute is the
// lowercase `cstheme` (OOXML inconsistency, not a typo).
// Exported so super-editor's calculateInlineRunPropertiesPlugin can consume the same
// list instead of duplicating it (SD-2894 follow-up).
export const FONT_SLOT_THEME_PAIRS: Array<[keyof RunFontFamilyProperties, keyof RunFontFamilyProperties]> = [
['ascii', 'asciiTheme'],
['hAnsi', 'hAnsiTheme'],
['eastAsia', 'eastAsiaTheme'],
['cs', 'cstheme'],
];

function dropConflictingFontSlots(
target: RunFontFamilyProperties,
source: RunFontFamilyProperties,
): RunFontFamilyProperties {
const result = { ...target };
for (const [concreteKey, themeKey] of FONT_SLOT_THEME_PAIRS) {
if (source[themeKey] != null) {
delete result[concreteKey];
delete result[themeKey];
} else if (source[concreteKey] != null) {
delete result[themeKey];
}
}
return result;
}

export function combineRunProperties(propertiesArray: RunProperties[]): RunProperties {
return combineProperties(propertiesArray, {
fullOverrideProps: ['color'],
specialHandling: {
fontFamily: (target: Record<string, unknown>, source: Record<string, unknown>): unknown => {
const fontFamilySource = { ...(source.fontFamily as object) } as RunFontFamilyProperties;
const fontFamilyTarget = { ...(target.fontFamily as object) } as RunFontFamilyProperties;
if (fontFamilySource.asciiTheme != null) {
delete fontFamilyTarget.ascii;
delete fontFamilyTarget.asciiTheme;
}
if (fontFamilySource.ascii != null) {
delete fontFamilyTarget.asciiTheme;
}
const fontFamilyTarget = dropConflictingFontSlots(
(target.fontFamily as RunFontFamilyProperties) ?? {},
fontFamilySource,
);
return { ...(fontFamilyTarget as object), ...(fontFamilySource as object) };
},
},
Expand Down
9 changes: 7 additions & 2 deletions packages/layout-engine/style-engine/src/ooxml/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
* This module is format-aware (docx), but translator-agnostic.
*/

import { combineIndentProperties, combineProperties, combineRunProperties } from '../cascade.js';
import {
combineIndentProperties,
combineProperties,
combineRunProperties,
FONT_SLOT_THEME_PAIRS,
} from '../cascade.js';
import type { PropertyObject } from '../cascade.js';
import type { ParagraphConditionalFormatting, ParagraphProperties, ParagraphTabStop, RunProperties } from './types.ts';
import type { NumberingProperties } from './numbering-types.ts';
Expand All @@ -18,7 +23,7 @@ import type {
TableCellProperties,
} from './styles-types.ts';

export { combineIndentProperties, combineProperties, combineRunProperties };
export { combineIndentProperties, combineProperties, combineRunProperties, FONT_SLOT_THEME_PAIRS };
export type { PropertyObject };
export type * from './types.ts';
export type * from './numbering-types.ts';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Plugin, TextSelection } from 'prosemirror-state';
import { Fragment } from 'prosemirror-model';
import { TableMap } from 'prosemirror-tables';
import { decodeRPrFromMarks, encodeMarksFromRPr, resolveRunProperties } from '@converter/styles.js';
import { FONT_SLOT_THEME_PAIRS } from '@superdoc/style-engine/ooxml';
import {
calculateResolvedParagraphProperties,
getResolvedParagraphProperties,
Expand All @@ -28,6 +29,85 @@ const RUN_PROPERTIES_DERIVED_FROM_MARKS = new Set([

export const TRANSIENT_HYPERLINK_STYLE_IDS = new Set(['Hyperlink', 'FollowedHyperlink']);

/**
* Merge mark-derived concrete fontFamily slots with theme references preserved on the
* existing run. For each slot where the existing fontFamily has a theme reference, keep
* the theme and drop the concrete value from marks; otherwise take the mark value.
*
* Only safe to call when the caller has already verified that marks are a re-derivation
* of `existing` rather than a user override — otherwise this silently reverts user font
* changes. See `marksMatchExistingFontFamily` for the gate.
*
* SD-2894: when the plugin overrides a run's fontFamily from mark-derived values, the
* mark only carries the resolved CSS font name (`ascii: "Calibri Light"`) and forgets the
* theme reference (`asciiTheme: "majorBidi"`) that came from the imported `<w:rFonts>`.
* Without this merge we'd export concrete font names that defeat Word's per-script theme
* resolution.
*
* AIDEV-NOTE: The gate + merge pair operate at the fontFamily-key level (whole rPr
* fontFamily object), not at the per-slot level. A user action that mutates only a
* per-script mark attr (`eastAsiaFontFamily` or `csFontFamily`) without changing the
* primary `fontFamily` will pass the gate, and this merge will silently restore the
* stale theme on that slot. We accept this trade-off because (a) the toolbar's
* setFontFamily writes all four slots to the same family, so a real user override
* always changes the primary; (b) no current API or paste path produces per-script-only
* mark changes against an imported theme run; (c) a per-slot model adds intricate
* encoder probes that risk regressing the SD-2894 customer fixture again (see commit
* bec7e63ed for the prior per-script regression we just fixed). If a real customer
* reproduction surfaces — e.g. a paste-from-Word flow with per-script fonts being
* silently reverted — file a follow-up ticket with that fixture and rework this helper
* into a per-slot decision then. Codex flagged this on the PR-3225 review.
*
* @param {Record<string, unknown>|null|undefined} fromMarks
* @param {Record<string, unknown>|null|undefined} existing
* @returns {Record<string, unknown>}
*/
function mergeFontFamilyPreservingThemeRefs(fromMarks, existing) {
const merged = { ...(fromMarks || {}) };
if (!existing || typeof existing !== 'object') return merged;
for (const [concreteKey, themeKey] of FONT_SLOT_THEME_PAIRS) {
if (existing[themeKey] != null) {
merged[themeKey] = existing[themeKey];
delete merged[concreteKey];
}
}
return merged;
}
Comment thread
tupizz marked this conversation as resolved.

/**
* True when the mark-derived fontFamily value encodes to the same primary ASCII font as
* the run's existing (imported) fontFamily — i.e., marks are a faithful re-derivation,
* not a user override.
*
* The encoder resolves theme references to their CSS font name. So:
* - import case: existing = { asciiTheme: 'majorBidi' }, marks = { ascii: 'Calibri Light' }
* → both encode to fontFamily: 'Calibri Light' → match → preserve theme.
* - user-edit case: existing = { asciiTheme: 'majorBidi' }, marks = { ascii: 'Arial' }
* → existing encodes to 'Calibri Light', marks encode to 'Arial' → mismatch → user
* has overridden, drop the theme and respect the new value.
*
* Compare ONLY the primary `fontFamily` attr, not the full mark attrs object. Per-script
* extras (`csFontFamily`, `eastAsiaFontFamily`) on the mark are derived only when the
* input has a *concrete* per-script slot. Existing run-props can carry mixed shapes
* (e.g. `{ asciiTheme, hAnsiTheme, cstheme, eastAsia: 'Arial' }`) that encode without
* `csFontFamily` while the mark re-decode adds a concrete `cs` and therefore does
* include it. A full-attrs JSON compare would falsely flag this as user override and
* drop the theme — the regression caught on the SD-2894 customer fixture round-trip.
*
* @param {{ attrs?: Record<string, unknown> } | null | undefined} markFromMarks
* @param {Record<string, unknown>|null|undefined} existingFontFamily
* @param {(props: Record<string, unknown>, docx: Record<string, unknown>) => Array<{ attrs?: Record<string, unknown> }>} encode
* @param {Record<string, unknown>} docx
* @returns {boolean}
*/
function marksMatchExistingFontFamily(markFromMarks, existingFontFamily, encode, docx) {
if (!existingFontFamily || typeof existingFontFamily !== 'object') return false;
if (!markFromMarks?.attrs) return false;
const markFromExisting = encode({ fontFamily: existingFontFamily }, docx)?.[0];
if (!markFromExisting?.attrs) return false;
return markFromMarks.attrs.fontFamily === markFromExisting.attrs.fontFamily;
}

const RUN_PROPERTY_PRESERVE_META_KEY = 'sdPreserveRunPropertiesKeys';
const COMPANION_INLINE_KEYS = {
fontSizeCs: 'fontSize',
Expand Down Expand Up @@ -536,10 +616,23 @@ function getInlineRunProperties(
const valueFromStyles = runPropertiesFromStyles[key];
if (JSON.stringify(valueFromMarks) !== JSON.stringify(valueFromStyles)) {
if (key === 'fontFamily') {
const markFromStyles = encodeMarksFromRPr({ [key]: valueFromStyles }, editor.converter?.convertedXml ?? {})[0];
const markFromMarks = encodeMarksFromRPr({ [key]: valueFromMarks }, editor.converter?.convertedXml ?? {})[0];
const docx = editor.converter?.convertedXml ?? {};
const markFromStyles = encodeMarksFromRPr({ [key]: valueFromStyles }, docx)[0];
const markFromMarks = encodeMarksFromRPr({ [key]: valueFromMarks }, docx)[0];
if (JSON.stringify(markFromMarks?.attrs) !== JSON.stringify(markFromStyles?.attrs)) {
inlineRunProperties[key] = valueFromMarks;
// SD-2894 follow-up (PR #3225 review): only preserve theme refs from `existing`
// when the marks are a faithful re-derivation of the imported value. If marks
// diverge from existing (user picked a new font), respect the user's choice
// and drop the stale theme reference.
const existingFontFamily = existingRunProperties?.[key];
inlineRunProperties[key] = marksMatchExistingFontFamily(
markFromMarks,
existingFontFamily,
encodeMarksFromRPr,
docx,
)
? mergeFontFamilyPreservingThemeRefs(valueFromMarks, existingFontFamily)
: valueFromMarks;
}
} else {
inlineRunProperties[key] = valueFromMarks;
Expand Down
Loading
Loading