diff --git a/packages/layout-engine/pm-adapter/src/attributes/tabs.ts b/packages/layout-engine/pm-adapter/src/attributes/tabs.ts index 33be9e5326..7b8ac69069 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/tabs.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/tabs.ts @@ -22,37 +22,6 @@ const PX_TO_TWIPS = 15; */ const TWIPS_THRESHOLD = 1000; -/** - * Input format from super-editor (nested structure) - */ -interface SuperEditorTabFormat { - tab: { - tabType: string; - pos: number; - leader?: string; - }; -} - -/** - * Input format from SuperConverter (flat structure) - */ -interface SuperConverterTabFormat { - val?: string; - align?: string; - alignment?: string; - type?: string; - pos?: number; - position?: number; - offset?: number; - originalPos?: number; - leader?: string; -} - -/** - * Union of supported input formats - */ -type _TabStopInput = SuperEditorTabFormat | SuperConverterTabFormat; - /** * Normalize OOXML tab stops from various input formats to canonical TabStop format. * diff --git a/packages/layout-engine/pm-adapter/src/constants.ts b/packages/layout-engine/pm-adapter/src/constants.ts index 587ec65273..fbb9a8a88e 100644 --- a/packages/layout-engine/pm-adapter/src/constants.ts +++ b/packages/layout-engine/pm-adapter/src/constants.ts @@ -4,7 +4,6 @@ import type { TextRun, TrackedChangeKind } from '@superdoc/contracts'; import type { HyperlinkConfig } from './types.js'; -import { SectionType } from './types.js'; /** * Unit conversion constants @@ -13,43 +12,6 @@ export const TWIPS_PER_INCH = 1440; export const PX_PER_INCH = 96; export const PX_PER_PT = 96 / 72; -/** - * Default typography settings - */ -export const DEFAULT_FONT = 'Arial'; -export const DEFAULT_SIZE = 16; - -/** - * List formatting defaults - */ -export const DEFAULT_LIST_INDENT_BASE_PX = 24; -export const DEFAULT_LIST_INDENT_STEP_PX = 24; -export const DEFAULT_LIST_HANGING_PX = 18; -export const DEFAULT_NUMBERING_TYPE = 'decimal'; -export const DEFAULT_LVL_TEXT = '%1.'; - -/** - * Locale defaults - */ -export const DEFAULT_DECIMAL_SEPARATOR = '.'; - -/** - * Section defaults - */ -export const DEFAULT_COLUMN_GAP_INCHES = 0.5; // 720 twips = 0.5 inches - -/** - * BiDi indentation defaults - */ -export const MIN_BIDI_CLAMP_INDENT_PX = 1; -export const DEFAULT_BIDI_INDENT_PX = 24; - -/** - * Section type defaults - */ -export const DEFAULT_PARAGRAPH_SECTION_TYPE: SectionType = SectionType.NEXT_PAGE; // Word's default when w:type omitted -export const DEFAULT_BODY_SECTION_TYPE: SectionType = SectionType.CONTINUOUS; // Body sectPr doesn't force page break at end - /** * Tracked changes mark types */ @@ -151,43 +113,3 @@ export const TOKEN_INLINE_TYPES = new Map([ ['page-number', 'pageNumber'], ['total-page-number', 'totalPageCount'], ]); - -/** - * Valid link target values - */ -export const VALID_LINK_TARGETS = new Set(['_blank', '_self', '_parent', '_top']); - -/** - * Bullet marker characters - */ -export const BULLET_MARKERS = ['•', '◦', '▪', '‣']; - -/** - * Valid wrap types for images/drawings - */ -export const WRAP_TYPES = new Set(['None', 'Square', 'Tight', 'Through', 'TopAndBottom', 'Inline']); - -/** - * Valid wrap text values - */ -export const WRAP_TEXT_VALUES = new Set(['bothSides', 'left', 'right', 'largest']); - -/** - * Valid horizontal relative positioning values - */ -export const H_RELATIVE_VALUES = new Set(['column', 'page', 'margin']); - -/** - * Valid vertical relative positioning values - */ -export const V_RELATIVE_VALUES = new Set(['paragraph', 'page', 'margin']); - -/** - * Valid horizontal alignment values - */ -export const H_ALIGN_VALUES = new Set(['left', 'center', 'right']); - -/** - * Valid vertical alignment values - */ -export const V_ALIGN_VALUES = new Set(['top', 'center', 'bottom']); diff --git a/packages/layout-engine/pm-adapter/src/converter-context.test.ts b/packages/layout-engine/pm-adapter/src/converter-context.test.ts index fe6937bad3..1c1a28494f 100644 --- a/packages/layout-engine/pm-adapter/src/converter-context.test.ts +++ b/packages/layout-engine/pm-adapter/src/converter-context.test.ts @@ -1,54 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { hasParagraphStyleContext, hasTableStyleContext } from './converter-context.js'; +import { hasTableStyleContext } from './converter-context.js'; import type { ConverterContext } from './converter-context.js'; -describe('hasParagraphStyleContext', () => { - it('should return false when context is undefined', () => { - const result = hasParagraphStyleContext(undefined); - expect(result).toBe(false); - }); - - it('should return true when context.docx is present but context.numbering is undefined', () => { - const context: ConverterContext = { - docx: { styles: {}, docDefaults: {} }, - }; - const result = hasParagraphStyleContext(context); - expect(result).toBe(true); - }); - - it('should return true when both context.docx and context.numbering are present', () => { - const context: ConverterContext = { - docx: { styles: {}, docDefaults: {} }, - numbering: { definitions: {}, abstracts: {} }, - }; - const result = hasParagraphStyleContext(context); - expect(result).toBe(true); - }); - - it('should return false when only context.numbering is present', () => { - const context: ConverterContext = { - numbering: { definitions: {}, abstracts: {} }, - }; - const result = hasParagraphStyleContext(context); - expect(result).toBe(false); - }); - - it('should return false when context is empty object', () => { - const context: ConverterContext = {}; - const result = hasParagraphStyleContext(context); - expect(result).toBe(false); - }); - - it('should return false when context.docx is undefined', () => { - const context: ConverterContext = { - docx: undefined, - numbering: { definitions: {}, abstracts: {} }, - }; - const result = hasParagraphStyleContext(context); - expect(result).toBe(false); - }); -}); - describe('hasTableStyleContext', () => { it('should return false when context is undefined', () => { const result = hasTableStyleContext(undefined); diff --git a/packages/layout-engine/pm-adapter/src/converter-context.ts b/packages/layout-engine/pm-adapter/src/converter-context.ts index 4fdd7b7275..c68f9da87e 100644 --- a/packages/layout-engine/pm-adapter/src/converter-context.ts +++ b/packages/layout-engine/pm-adapter/src/converter-context.ts @@ -11,19 +11,6 @@ import type { ParagraphSpacing } from '@superdoc/contracts'; import type { NumberingProperties, StylesDocumentProperties, TableInfo } from '@superdoc/style-engine/ooxml'; -export type ConverterNumberingContext = { - definitions?: Record; - abstracts?: Record; -}; - -export type ConverterLinkedStyle = { - id: string; - definition?: { - styles?: Record; - attrs?: Record; - }; -}; - /** * Paragraph properties from a table style that should be applied to * paragraphs inside table cells as part of the OOXML style cascade. @@ -34,8 +21,6 @@ export type TableStyleParagraphProps = { export type ConverterContext = { docx?: Record; - numbering?: ConverterNumberingContext; - linkedStyles?: ConverterLinkedStyle[]; translatedNumbering: NumberingProperties; translatedLinkedStyles: StylesDocumentProperties; /** @@ -61,20 +46,6 @@ export type ConverterContext = { backgroundColor?: string; }; -/** - * Guard that checks whether the converter context includes DOCX data - * required for paragraph style hydration. - * - * Paragraph hydration needs DOCX structures so it can follow style - * inheritance chains via resolveParagraphProperties. Numbering is optional - * since documents without lists should still get docDefaults spacing. - */ -export const hasParagraphStyleContext = ( - context?: ConverterContext, -): context is ConverterContext & { docx: Record } => { - return Boolean(context?.docx); -}; - /** * Guard that checks whether DOCX data is available for table style lookups. * diff --git a/packages/layout-engine/pm-adapter/src/internal.test.ts b/packages/layout-engine/pm-adapter/src/internal.test.ts index 27e9ed570f..3e2236965f 100644 --- a/packages/layout-engine/pm-adapter/src/internal.test.ts +++ b/packages/layout-engine/pm-adapter/src/internal.test.ts @@ -62,15 +62,7 @@ vi.mock('./sections/index.js', () => { }); vi.mock('./utilities.js', () => ({ - pxToPt: vi.fn((px) => (px != null ? px / 1.333 : undefined)), pickNumber: vi.fn((value) => (typeof value === 'number' ? value : undefined)), - pickDecimalSeparator: vi.fn((value) => { - if (typeof value === 'string' && (value.trim() === '.' || value.trim() === ',')) { - return value.trim(); - } - return undefined; - }), - pickLang: vi.fn((value) => (typeof value === 'string' ? value.toLowerCase() : undefined)), normalizePrefix: vi.fn((value) => (value ? String(value) : '')), buildPositionMap: vi.fn(() => new WeakMap()), createBlockIdGenerator: vi.fn((prefix = '') => { @@ -646,7 +638,6 @@ describe('internal', () => { toFlowBlocks(doc); - // pickLang should be called with the lang value expect(handleParagraphNode).toHaveBeenCalled(); }); diff --git a/packages/layout-engine/pm-adapter/src/internal.ts b/packages/layout-engine/pm-adapter/src/internal.ts index 09a86e1993..5651802376 100644 --- a/packages/layout-engine/pm-adapter/src/internal.ts +++ b/packages/layout-engine/pm-adapter/src/internal.ts @@ -66,7 +66,6 @@ export const nodeHandlers: Record = { documentSection: handleDocumentSectionNode, table: handleTableNode, documentPartObject: handleDocumentPartObjectNode, - // orderedList and bulletList removed - list handling moved out of layout-engine image: handleImageNode, vectorShape: handleVectorShapeNode, shapeGroup: handleShapeGroupNode, @@ -74,7 +73,7 @@ export const nodeHandlers: Record = { shapeTextbox: handleShapeTextboxNode, }; -export const converters: NestedConverters = { +const converters: NestedConverters = { contentBlockNodeToDrawingBlock, imageNodeToBlock, vectorShapeNodeToDrawingBlock, diff --git a/packages/layout-engine/pm-adapter/src/node-handlers/index.ts b/packages/layout-engine/pm-adapter/src/node-handlers/index.ts deleted file mode 100644 index 589a30fa15..0000000000 --- a/packages/layout-engine/pm-adapter/src/node-handlers/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Node Handlers Module - * - * Exports handler functions and dispatcher for processing PM nodes. - * - * Note: Handlers are currently defined in internal.ts and re-exported here - * for module organization. Future refactoring can extract them fully. - */ - -export { nodeHandlers } from '../internal.js'; diff --git a/packages/layout-engine/pm-adapter/src/styles/linked-run.test.ts b/packages/layout-engine/pm-adapter/src/styles/linked-run.test.ts deleted file mode 100644 index dd792aceb9..0000000000 --- a/packages/layout-engine/pm-adapter/src/styles/linked-run.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { applyLinkedStyleToRun, createLinkedStyleResolver } from './linked-run.js'; -import type { TextRun } from '@superdoc/contracts'; - -describe('linked-run style resolver', () => { - beforeEach(() => { - vi.spyOn(console, 'debug').mockImplementation(() => undefined); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('applies inherited styles to runs when defaults are present', () => { - const resolver = createLinkedStyleResolver([ - { id: 'Base', definition: { styles: { 'font-family': 'Calibri', bold: true } } }, - { - id: 'Hyperlink', - definition: { attrs: { basedOn: 'Base' }, styles: { color: '#0066CC', underline: 'single' } }, - }, - ]); - expect(resolver).not.toBeNull(); - const run: TextRun = { - text: 'link', - fontFamily: 'Default', - fontSize: 16, - }; - applyLinkedStyleToRun(run, { - resolver: resolver!, - paragraphStyleId: null, - inlineStyleId: null, - runStyleId: 'Hyperlink', - defaultFont: 'Default', - defaultSize: 16, - }); - expect(run.fontFamily).toBe('Calibri'); - expect(run.bold).toBe(true); - expect(run.color).toBe('#0066CC'); - expect(run.underline?.style).toBe('single'); - }); - - it('respects paragraph style precedence for inline style IDs', () => { - const resolver = createLinkedStyleResolver([ - { id: 'BodyText', definition: { styles: { 'font-family': 'Body', 'font-size': '12pt' } } }, - { id: 'Inline', definition: { styles: { 'font-size': '18pt' } } }, - ]); - const run: TextRun = { - text: 'sample', - fontFamily: 'Default', - fontSize: 16, - }; - applyLinkedStyleToRun(run, { - resolver: resolver!, - paragraphStyleId: 'BodyText', - inlineStyleId: 'Inline', - defaultFont: 'Default', - defaultSize: 16, - }); - // Paragraph style should set font family, inline style should override size - expect(run.fontFamily).toBe('Body'); - expect(run.fontSize).toBeCloseTo((18 * 96) / 72); - }); - - it('skips inline styles for TOC paragraphs', () => { - const resolver = createLinkedStyleResolver([ - { id: 'TOC1', definition: { styles: { color: '#111111' } } }, - { id: 'CharacterStyle', definition: { styles: { color: '#FF0000' } } }, - ]); - const run: TextRun = { - text: 'entry', - fontFamily: 'Default', - fontSize: 16, - }; - applyLinkedStyleToRun(run, { - resolver: resolver!, - paragraphStyleId: 'TOC1', - inlineStyleId: 'CharacterStyle', - defaultFont: 'Default', - defaultSize: 16, - }); - expect(run.color).toBe('#111111'); - }); - - it('always applies fontSize from linked styles regardless of default value', () => { - const resolver = createLinkedStyleResolver([ - { id: 'CustomStyle', definition: { styles: { 'font-size': '14pt' } } }, - ]); - const run: TextRun = { - text: 'test', - fontFamily: 'Default', - fontSize: 16, // Default size - }; - applyLinkedStyleToRun(run, { - resolver: resolver!, - paragraphStyleId: null, - inlineStyleId: null, - runStyleId: 'CustomStyle', - defaultFont: 'Default', - defaultSize: 16, - }); - // Should apply 14pt converted to pixels, even if run currently has default size - const expectedPx = (14 * 96) / 72; - expect(run.fontSize).toBeCloseTo(expectedPx); - }); - - it('applies fontSize from linked styles when run has non-default size', () => { - const resolver = createLinkedStyleResolver([ - { id: 'CustomStyle', definition: { styles: { 'font-size': '20pt' } } }, - ]); - const run: TextRun = { - text: 'test', - fontFamily: 'Default', - fontSize: 18, // Non-default size - }; - applyLinkedStyleToRun(run, { - resolver: resolver!, - paragraphStyleId: null, - inlineStyleId: null, - runStyleId: 'CustomStyle', - defaultFont: 'Default', - defaultSize: 16, - }); - // Should apply 20pt converted to pixels - const expectedPx = (20 * 96) / 72; - expect(run.fontSize).toBeCloseTo(expectedPx); - }); - - it('allows marks applied after to override linked style fontSize', () => { - const resolver = createLinkedStyleResolver([ - { id: 'CustomStyle', definition: { styles: { 'font-size': '14pt', color: '#0000FF' } } }, - ]); - const run: TextRun = { - text: 'test', - fontFamily: 'Default', - fontSize: 16, - }; - - // First apply linked styles - applyLinkedStyleToRun(run, { - resolver: resolver!, - paragraphStyleId: null, - inlineStyleId: null, - runStyleId: 'CustomStyle', - defaultFont: 'Default', - defaultSize: 16, - }); - - // Verify linked styles were applied - const linkedFontSizePx = (14 * 96) / 72; - expect(run.fontSize).toBeCloseTo(linkedFontSizePx); - expect(run.color).toBe('#0000FF'); - - // Simulate marks being applied after (which should override) - run.fontSize = 24; // Override from mark - run.color = '#FF0000'; // Override from mark - - // Verify marks override linked styles - expect(run.fontSize).toBe(24); - expect(run.color).toBe('#FF0000'); - }); - - it('applies all style properties from linked styles when present', () => { - const resolver = createLinkedStyleResolver([ - { - id: 'RichStyle', - definition: { - styles: { - 'font-size': '18pt', - 'font-family': 'Georgia', - color: '#333333', - bold: true, - italic: true, - underline: 'double', - 'letter-spacing': '2pt', - }, - }, - }, - ]); - const run: TextRun = { - text: 'test', - fontFamily: 'Default', - fontSize: 16, - }; - applyLinkedStyleToRun(run, { - resolver: resolver!, - paragraphStyleId: null, - inlineStyleId: null, - runStyleId: 'RichStyle', - defaultFont: 'Default', - defaultSize: 16, - }); - - expect(run.fontSize).toBeCloseTo((18 * 96) / 72); - expect(run.fontFamily).toBe('Georgia'); - expect(run.color).toBe('#333333'); - expect(run.bold).toBe(true); - expect(run.italic).toBe(true); - expect(run.underline?.style).toBe('double'); - expect(run.letterSpacing).toBeCloseTo((2 * 96) / 72); - }); -}); diff --git a/packages/layout-engine/pm-adapter/src/styles/linked-run.ts b/packages/layout-engine/pm-adapter/src/styles/linked-run.ts deleted file mode 100644 index 5c8a0332e4..0000000000 --- a/packages/layout-engine/pm-adapter/src/styles/linked-run.ts +++ /dev/null @@ -1,250 +0,0 @@ -import type { TextRun } from '@superdoc/contracts'; -import type { ConverterLinkedStyle } from '../converter-context.js'; - -type StyleRecord = Record; - -const extractValue = (value: unknown): unknown => { - if (value && typeof value === 'object' && 'value' in (value as Record)) { - return (value as Record).value; - } - return value; -}; - -const toBoolean = (value: unknown): boolean | undefined => { - const raw = extractValue(value); - if (raw == null) return undefined; - if (typeof raw === 'boolean') return raw; - if (typeof raw === 'number') return raw !== 0; - if (typeof raw === 'string') { - const normalized = raw.trim().toLowerCase(); - if (!normalized) return undefined; - if (['0', 'false', 'off', 'none'].includes(normalized)) return false; - return true; - } - return Boolean(raw); -}; - -const toColor = (value: unknown): string | undefined => { - const raw = extractValue(value); - if (typeof raw !== 'string') return undefined; - if (!raw) return undefined; - if (raw.startsWith('#')) return raw; - return `#${raw}`; -}; - -/** - * Converts an underline value to a valid UnderlineStyle. - * Handles multiple format variations from different parts of the conversion pipeline. - * - * @param value - The underline value, which can be: - * - A direct string like 'single', 'double', 'dotted', 'dashed', 'wavy' - * - An object with { underline: 'single' } from parseMarks/getDefaultStyleDefinition - * - An object with { underlineType: 'single' } from encodeMarksFromRPr - * - An object with { value: 'single' } legacy format - * @returns A valid UnderlineStyle ('single', 'double', 'dotted', 'dashed', 'wavy'), or undefined for 'none'/empty values - */ -const toUnderlineStyle = (value: unknown): 'single' | 'double' | 'dotted' | 'dashed' | 'wavy' | undefined => { - // Handle multiple possible formats for underline value: - // - { underline: 'single' } from parseMarks/getDefaultStyleDefinition - // - { underlineType: 'single' } from encodeMarksFromRPr - // - { value: 'single' } legacy format - // - 'single' as a direct string - let raw: unknown; - if (value && typeof value === 'object') { - const obj = value as Record; - raw = obj.underline ?? obj.underlineType ?? obj.value ?? value; - } else { - raw = extractValue(value); - } - const normalized = `${raw ?? ''}`.toLowerCase(); - if (!normalized || normalized === 'none' || normalized === '0' || normalized === '[object object]') { - return undefined; - } - if (normalized === 'double' || normalized === 'dotted' || normalized === 'dashed' || normalized === 'wavy') { - return normalized; - } - return 'single'; -}; - -const PT_TO_PX = 96 / 72; - -const toPxNumber = (value: unknown): number | undefined => { - const raw = extractValue(value); - if (typeof raw === 'number' && Number.isFinite(raw)) return raw; - if (typeof raw !== 'string') return undefined; - const trimmed = raw.trim(); - const match = trimmed.match(/^(-?\d+(\.\d+)?)([a-z%]*)$/i); - if (!match) return undefined; - const numeric = parseFloat(match[1]); - if (!Number.isFinite(numeric)) return undefined; - const unit = match[3]?.toLowerCase(); - if (!unit || unit === 'px') return numeric; - if (unit === 'pt') return numeric * PT_TO_PX; - return numeric; -}; - -// Maximum style inheritance depth. Word's internal limit is similar (~15-20). -// Prevents pathological O(n²) style chains and circular references. -const MAX_INHERITANCE_DEPTH = 20; - -export class LinkedStyleResolver { - #map: Map; - - constructor(styles: ConverterLinkedStyle[]) { - this.#map = new Map(); - styles.forEach((style) => style?.id && this.#map.set(style.id, style)); - } - - getStyleMap(styleId?: string | null): StyleRecord { - if (!styleId || typeof styleId !== 'string') return {}; - - const visited = new Set(); - const stack: ConverterLinkedStyle[] = []; - let cursor = this.#map.get(styleId); - let depth = 0; - - while (cursor && !visited.has(cursor.id)) { - // Guard against infinite loops - if (++depth > MAX_INHERITANCE_DEPTH) { - console.warn(`Style inheritance depth exceeded for: ${styleId}`); - break; - } - - stack.unshift(cursor); - visited.add(cursor.id); - - const basedOn = cursor.definition?.attrs?.basedOn; - if (!basedOn || typeof basedOn !== 'string') break; - - cursor = this.#map.get(basedOn); - } - - const merged: StyleRecord = {}; - stack.forEach((style) => { - Object.assign(merged, style.definition?.styles || {}); - }); - return merged; - } -} - -export const createLinkedStyleResolver = (styles?: ConverterLinkedStyle[] | null): LinkedStyleResolver | null => { - if (!styles || styles.length === 0) return null; - return new LinkedStyleResolver(styles); -}; - -type RunStyleOptions = { - resolver: LinkedStyleResolver; - paragraphStyleId?: string | null; - inlineStyleId?: string | null; - runStyleId?: string | null; - defaultFont: string; - defaultSize: number; -}; - -export const applyLinkedStyleToRun = (run: TextRun, options: RunStyleOptions): void => { - const { resolver, paragraphStyleId, inlineStyleId, runStyleId } = options; - const maps: StyleRecord[] = []; - if (paragraphStyleId) { - const pMap = resolver.getStyleMap(paragraphStyleId); - maps.push(pMap); - } - if (inlineStyleId && !paragraphStyleId?.startsWith('TOC')) { - const iMap = resolver.getStyleMap(inlineStyleId); - maps.push(iMap); - } - if (runStyleId) { - const rMap = resolver.getStyleMap(runStyleId); - maps.push(rMap); - } - if (!maps.length) return; - - const finalStyles = Object.assign({}, ...maps); - const appliedKeys: string[] = []; - - // Apply font family from linked styles (marks will override if they have explicit fontFamily) - const fontFamily = extractValue(finalStyles['font-family']); - if (typeof fontFamily === 'string' && fontFamily) { - run.fontFamily = fontFamily; - appliedKeys.push('font-family'); - } - - // Apply font size from linked styles (marks will override if they have explicit fontSize) - // Note: We no longer check run.fontSize === defaultSize because this function is now called - // BEFORE marks are applied, so marks can properly override linked style values. - const fontSize = toPxNumber(finalStyles['font-size']); - if (fontSize != null) { - run.fontSize = fontSize; - appliedKeys.push('font-size'); - } - - const letterSpacing = toPxNumber(finalStyles['letter-spacing']); - if (letterSpacing != null && run.letterSpacing == null) { - run.letterSpacing = letterSpacing; - appliedKeys.push('letter-spacing'); - } - - const color = toColor(finalStyles.color); - if (color && !run.color) { - run.color = color; - appliedKeys.push('color'); - } - - const highlight = toColor(finalStyles.highlight); - if (highlight && !run.highlight) { - run.highlight = highlight; - appliedKeys.push('highlight'); - } - - const bold = toBoolean(finalStyles.bold); - if (bold && run.bold === undefined) { - run.bold = true; - appliedKeys.push('bold'); - } - - const italic = toBoolean(finalStyles.italic); - if (italic && run.italic === undefined) { - run.italic = true; - appliedKeys.push('italic'); - } - - const strike = toBoolean(finalStyles.strike); - if (strike && run.strike === undefined) { - run.strike = true; - appliedKeys.push('strike'); - } - - const underlineStyle = toUnderlineStyle(finalStyles.underline); - if (underlineStyle && !run.underline) { - run.underline = { style: underlineStyle }; - appliedKeys.push('underline'); - } -}; - -export const extractRunStyleId = (runProperties: unknown): string | null => { - if (!runProperties) return null; - if (typeof runProperties === 'object' && !Array.isArray(runProperties)) { - const styleId = (runProperties as Record).styleId; - if (typeof styleId === 'string' && styleId.trim()) { - return styleId; - } - const formatting = (runProperties as Record).formatting; - if ( - formatting && - typeof formatting === 'object' && - typeof (formatting as Record).styleId === 'string' - ) { - return (formatting as Record).styleId as string; - } - } - if (Array.isArray(runProperties)) { - const entry = runProperties.find( - (node) => node && typeof node === 'object' && (node as Record).xmlName === 'w:rStyle', - ) as Record | undefined; - const attributes = entry?.attributes as Record | undefined; - const val = attributes?.['w:val']; - if (typeof val === 'string' && val.trim()) { - return val; - } - } - return null; -}; diff --git a/packages/layout-engine/pm-adapter/src/tracked-changes.test.ts b/packages/layout-engine/pm-adapter/src/tracked-changes.test.ts index c54fcf206c..3e8fda8616 100644 --- a/packages/layout-engine/pm-adapter/src/tracked-changes.test.ts +++ b/packages/layout-engine/pm-adapter/src/tracked-changes.test.ts @@ -21,7 +21,6 @@ import { buildTrackedChangeMetaFromMark, selectTrackedChangeMeta, trackedChangesCompatible, - collectTrackedChangeFromMarks, shouldHideTrackedNode, annotateBlockWithTrackedChange, resetRunFormatting, @@ -492,54 +491,6 @@ describe('tracked-changes', () => { }); }); - describe('collectTrackedChangeFromMarks', () => { - it('should return undefined for empty marks array', () => { - expect(collectTrackedChangeFromMarks([])).toBeUndefined(); - }); - - it('should return undefined for undefined marks', () => { - expect(collectTrackedChangeFromMarks(undefined)).toBeUndefined(); - }); - - it('should return undefined when no tracked change marks present', () => { - const marks: PMMark[] = [{ type: 'bold' }, { type: 'italic' }]; - expect(collectTrackedChangeFromMarks(marks)).toBeUndefined(); - }); - - it('should collect single tracked change mark', () => { - const marks: PMMark[] = [{ type: 'trackInsert', attrs: { id: 'ins-1' } }, { type: 'bold' }]; - const result = collectTrackedChangeFromMarks(marks); - expect(result).toEqual({ kind: 'insert', id: 'ins-1' }); - }); - - it('should prioritize insert over format when both present', () => { - const marks: PMMark[] = [ - { type: 'trackFormat', attrs: { id: 'fmt-1' } }, - { type: 'trackInsert', attrs: { id: 'ins-1' } }, - ]; - const result = collectTrackedChangeFromMarks(marks); - expect(result?.kind).toBe('insert'); - }); - - it('should prioritize delete over format when both present', () => { - const marks: PMMark[] = [ - { type: 'trackFormat', attrs: { id: 'fmt-1' } }, - { type: 'trackDelete', attrs: { id: 'del-1' } }, - ]; - const result = collectTrackedChangeFromMarks(marks); - expect(result?.kind).toBe('delete'); - }); - - it('should keep first insert when multiple inserts present', () => { - const marks: PMMark[] = [ - { type: 'trackInsert', attrs: { id: 'ins-1' } }, - { type: 'trackInsert', attrs: { id: 'ins-2' } }, - ]; - const result = collectTrackedChangeFromMarks(marks); - expect(result?.id).toBe('ins-1'); - }); - }); - describe('shouldHideTrackedNode', () => { it('should return false when metadata is undefined', () => { const config: TrackedChangesConfig = { enabled: true, mode: 'original' }; diff --git a/packages/layout-engine/pm-adapter/src/tracked-changes.ts b/packages/layout-engine/pm-adapter/src/tracked-changes.ts index fa0b6b46ad..1bc7a89727 100644 --- a/packages/layout-engine/pm-adapter/src/tracked-changes.ts +++ b/packages/layout-engine/pm-adapter/src/tracked-changes.ts @@ -271,22 +271,6 @@ export const trackedChangesCompatible = (a: TextRun, b: TextRun): boolean => { return aMeta.kind === bMeta.kind && aMeta.id === bMeta.id; }; -/** - * Collects and prioritizes tracked change metadata from an array of ProseMirror marks. - * When multiple tracked change marks are present, returns the highest-priority one. - * - * @param marks - Array of ProseMirror marks to process - * @returns The highest-priority TrackedChangeMeta, or undefined if none found - */ -export const collectTrackedChangeFromMarks = (marks?: PMMark[]): TrackedChangeMeta | undefined => { - if (!marks || !marks.length) return undefined; - return marks.reduce((current, mark) => { - const meta = buildTrackedChangeMetaFromMark(mark); - if (!meta) return current; - return selectTrackedChangeMeta(current, meta); - }, undefined); -}; - /** * Determines if a tracked node should be hidden based on the viewing mode * diff --git a/packages/layout-engine/pm-adapter/src/types.ts b/packages/layout-engine/pm-adapter/src/types.ts index aabc6c3f59..758d469f03 100644 --- a/packages/layout-engine/pm-adapter/src/types.ts +++ b/packages/layout-engine/pm-adapter/src/types.ts @@ -229,16 +229,6 @@ export type Position = { start: number; end: number }; */ export type PositionMap = WeakMap; -/** - * Bookmark pair tracking - */ -export type BookmarkPair = number; - -/** - * Block position data - */ -export type BlockPositionData = Position; - /** * PM document map for batch processing */ @@ -345,27 +335,6 @@ export type NestedConverters = { shapeTextboxNodeToDrawingBlock: typeof shapeTextboxNodeToDrawingBlock; }; -/** - * List rendering attributes - */ -export type ListRenderingAttrs = { - markerText: string; - justification: 'left' | 'right' | 'center'; - path: number[]; - numberingType: string; - suffix: 'tab' | 'space' | 'nothing'; -}; - -/** - * Marker parameters for list items - */ -export type MarkerParams = { - listType: 'bullet' | 'ordered'; - listNode: PMNode; - itemNode: PMNode; - fallbackOrder: number; -}; - /** * OOXML border specification */ diff --git a/packages/layout-engine/pm-adapter/src/utilities.d.ts b/packages/layout-engine/pm-adapter/src/utilities.d.ts index d841e23f60..20b81a0eb1 100644 --- a/packages/layout-engine/pm-adapter/src/utilities.d.ts +++ b/packages/layout-engine/pm-adapter/src/utilities.d.ts @@ -42,39 +42,6 @@ export declare const twipsToPx: (value: number) => number; * ``` */ export declare const ptToPx: (pt?: number | null) => number | undefined; -/** - * Converts a value from pixels to points. - * - * @param px - The value in pixels to convert (optional, nullable) - * @returns The equivalent value in points, or undefined if input is null/undefined/not finite - * - * @example - * ```typescript - * const points = pxToPt(16); // 12pt (16px at 96 DPI) - * pxToPt(null); // undefined - * pxToPt(Infinity); // undefined - * ``` - */ -export declare const pxToPt: (px?: number | null) => number | undefined; -/** - * Converts paragraph indent values from twips to pixels. - * - * Takes an indent object with potentially four properties (left, right, firstLine, hanging) - * and converts any finite numeric values from twips to pixels. - * - * @param indent - The paragraph indent object with values in twips (optional, nullable) - * @returns A new indent object with values in pixels, or undefined if no valid values exist - * - * @example - * ```typescript - * const pxIndent = convertIndentTwipsToPx({ left: 1440, firstLine: 720 }); - * // { left: 96, firstLine: 48 } - * - * convertIndentTwipsToPx(null); // undefined - * convertIndentTwipsToPx({}); // undefined (no valid properties) - * ``` - */ -export declare const convertIndentTwipsToPx: (indent?: ParagraphIndent | null) => ParagraphIndent | undefined; /** * Type guard to check if a value is a finite number. * @@ -149,41 +116,6 @@ export declare const normalizePrefix: (value?: string) => string; * ``` */ export declare const pickNumber: (value: unknown) => number | undefined; -/** - * Validates and normalizes a decimal separator character. - * - * Only accepts '.' or ',' as valid decimal separators. - * - * @param value - The value to validate as a decimal separator - * @returns The normalized separator ('.' or ','), or undefined if invalid - * - * @example - * ```typescript - * pickDecimalSeparator("."); // "." - * pickDecimalSeparator(","); // "," - * pickDecimalSeparator(" . "); // "." - * pickDecimalSeparator(";"); // undefined - * pickDecimalSeparator(123); // undefined - * ``` - */ -export declare const pickDecimalSeparator: (value: unknown) => string | undefined; -/** - * Extracts and normalizes a language code string. - * - * Trims whitespace and converts to lowercase. Returns undefined for empty strings. - * - * @param value - The language code to normalize - * @returns The normalized language code, or undefined if invalid or empty - * - * @example - * ```typescript - * pickLang("EN-US"); // "en-us" - * pickLang(" fr "); // "fr" - * pickLang(""); // undefined - * pickLang(123); // undefined - * ``` - */ -export declare const pickLang: (value: unknown) => string | undefined; /** * Normalizes a color string, ensuring it has a leading '#' symbol. * @@ -323,60 +255,6 @@ export declare function coerceBoolean(value: unknown): boolean | undefined; * ``` */ export declare const toBoolean: (value: unknown) => boolean | undefined; -/** - * Checks if a value is explicitly truthy according to specific patterns. - * - * Unlike coerceBoolean which returns undefined for unrecognized values, this - * function always returns a definite boolean. It returns true ONLY for explicitly - * truthy values, and false for everything else (including unrecognized values). - * - * Use this when you need a definite boolean answer and want to treat unknown - * values as false rather than undefined. - * - * Recognized truthy values: true, 1, 'true', '1', 'on' - * - * @param value - The value to check for truthiness - * @returns True if the value matches truthy patterns, false otherwise - * - * @example - * ```typescript - * isTruthy(true); // true - * isTruthy(1); // true - * isTruthy("true"); // true - * isTruthy("on"); // true - * isTruthy(false); // false - * isTruthy(0); // false - * isTruthy("yes"); // false (not in recognized patterns) - * isTruthy("maybe"); // false - * isTruthy(null); // false - * ``` - */ -export declare const isTruthy: (value: unknown) => boolean; -/** - * Checks if a value is explicitly false according to specific patterns. - * - * Similar to isTruthy, this always returns a definite boolean. It returns true - * ONLY when the value explicitly indicates false, not for unrecognized values. - * - * Recognized falsy values: false, 0, 'false', '0', 'off' - * - * @param value - The value to check for explicit falseness - * @returns True if the value matches explicit false patterns, false otherwise - * - * @example - * ```typescript - * isExplicitFalse(false); // true - * isExplicitFalse(0); // true - * isExplicitFalse("false"); // true - * isExplicitFalse("off"); // true - * isExplicitFalse(true); // false - * isExplicitFalse(1); // false - * isExplicitFalse("no"); // false (not in recognized patterns) - * isExplicitFalse("maybe"); // false - * isExplicitFalse(null); // false - * ``` - */ -export declare const isExplicitFalse: (value: unknown) => boolean; /** * Converts a spacing object to a BoxSpacing type with validated numeric values. * diff --git a/packages/layout-engine/pm-adapter/src/utilities.test.ts b/packages/layout-engine/pm-adapter/src/utilities.test.ts index bb3a8bee94..b5a3a4d4e4 100644 --- a/packages/layout-engine/pm-adapter/src/utilities.test.ts +++ b/packages/layout-engine/pm-adapter/src/utilities.test.ts @@ -4,26 +4,20 @@ */ import { describe, it, expect } from 'vitest'; -import type { FlowBlock, ParagraphIndent } from '@superdoc/contracts'; +import type { FlowBlock } from '@superdoc/contracts'; import { twipsToPx, ptToPx, - pxToPt, - convertIndentTwipsToPx, isFiniteNumber, isPlainObject, normalizePrefix, pickNumber, - pickDecimalSeparator, - pickLang, normalizeColor, normalizeString, coerceNumber, coercePositiveNumber, coerceBoolean, toBoolean, - isTruthy, - isExplicitFalse, toBoxSpacing, normalizeMediaKey, inferExtensionFromPath, @@ -78,72 +72,6 @@ describe('Unit Conversion', () => { expect(ptToPx(-Infinity)).toBeUndefined(); }); }); - - describe('pxToPt', () => { - it('converts pixels to points', () => { - expect(pxToPt(16)).toBeCloseTo(12, 1); - expect(pxToPt(0)).toBe(0); - expect(pxToPt(96)).toBe(72); // 96px = 1 inch = 72pt - }); - - it('returns undefined for null/undefined/non-finite', () => { - expect(pxToPt(null)).toBeUndefined(); - expect(pxToPt(undefined)).toBeUndefined(); - expect(pxToPt(NaN)).toBeUndefined(); - expect(pxToPt(Infinity)).toBeUndefined(); - }); - }); - - describe('convertIndentTwipsToPx', () => { - it('converts all indent properties', () => { - const result = convertIndentTwipsToPx({ - left: 1440, - right: 720, - firstLine: 360, - hanging: 180, - }); - expect(result).toEqual({ - left: 96, - right: 48, - firstLine: 24, - hanging: 12, - }); - }); - - it('handles partial indent objects', () => { - const result = convertIndentTwipsToPx({ left: 1440 }); - expect(result).toEqual({ left: 96 }); - }); - - it('returns undefined for null/undefined', () => { - expect(convertIndentTwipsToPx(null)).toBeUndefined(); - expect(convertIndentTwipsToPx(undefined)).toBeUndefined(); - }); - - it('returns undefined for empty indent', () => { - expect(convertIndentTwipsToPx({})).toBeUndefined(); - }); - - it('ignores non-finite values', () => { - const result = convertIndentTwipsToPx({ - left: 1440, - right: NaN, - firstLine: Infinity, - } as ParagraphIndent); - expect(result).toEqual({ left: 96 }); - }); - - it('handles multiple valid properties', () => { - const result = convertIndentTwipsToPx({ - left: 720, - hanging: 360, - }); - expect(result).toEqual({ - left: 48, - hanging: 24, - }); - }); - }); }); // ============================================================================ @@ -244,47 +172,6 @@ describe('Normalization', () => { }); }); - describe('pickDecimalSeparator', () => { - it('accepts valid decimal separators', () => { - expect(pickDecimalSeparator('.')).toBe('.'); - expect(pickDecimalSeparator(',')).toBe(','); - }); - - it('trims whitespace', () => { - expect(pickDecimalSeparator(' . ')).toBe('.'); - expect(pickDecimalSeparator(' , ')).toBe(','); - }); - - it('returns undefined for invalid values', () => { - expect(pickDecimalSeparator(';')).toBeUndefined(); - expect(pickDecimalSeparator('.')).toBe('.'); - expect(pickDecimalSeparator(42 as never)).toBeUndefined(); - expect(pickDecimalSeparator(null as never)).toBeUndefined(); - }); - }); - - describe('pickLang', () => { - it('normalizes language codes', () => { - expect(pickLang('en-US')).toBe('en-us'); - expect(pickLang('FR')).toBe('fr'); - }); - - it('trims whitespace', () => { - expect(pickLang(' en ')).toBe('en'); - }); - - it('returns undefined for non-strings', () => { - expect(pickLang(null as never)).toBeUndefined(); - expect(pickLang(undefined as never)).toBeUndefined(); - expect(pickLang(42 as never)).toBeUndefined(); - }); - - it('returns undefined for empty strings', () => { - expect(pickLang('')).toBeUndefined(); - expect(pickLang(' ')).toBeUndefined(); - }); - }); - describe('normalizeColor', () => { it('adds # prefix when missing', () => { expect(normalizeColor('FF0000')).toBe('#FF0000'); @@ -486,45 +373,6 @@ describe('Coercion', () => { expect(toBoolean(undefined)).toBeUndefined(); }); }); - - describe('isTruthy', () => { - it('returns true for explicit truthy values', () => { - expect(isTruthy(true)).toBe(true); - expect(isTruthy(1)).toBe(true); - expect(isTruthy('true')).toBe(true); - expect(isTruthy('1')).toBe(true); - expect(isTruthy('on')).toBe(true); - }); - - it('returns false for falsy and unknown values', () => { - expect(isTruthy(false)).toBe(false); - expect(isTruthy(0)).toBe(false); - expect(isTruthy('false')).toBe(false); - expect(isTruthy('no')).toBe(false); - expect(isTruthy('yes')).toBe(false); // Only recognizes true/1/on - expect(isTruthy(null)).toBe(false); - expect(isTruthy(undefined)).toBe(false); - }); - }); - - describe('isExplicitFalse', () => { - it('returns true for explicit false values', () => { - expect(isExplicitFalse(false)).toBe(true); - expect(isExplicitFalse(0)).toBe(true); - expect(isExplicitFalse('false')).toBe(true); - expect(isExplicitFalse('FALSE')).toBe(true); - expect(isExplicitFalse('0')).toBe(true); - expect(isExplicitFalse('off')).toBe(true); - }); - - it('returns false for non-false values', () => { - expect(isExplicitFalse(true)).toBe(false); - expect(isExplicitFalse(1)).toBe(false); - expect(isExplicitFalse('true')).toBe(false); - expect(isExplicitFalse(null)).toBe(false); - expect(isExplicitFalse(undefined)).toBe(false); - }); - }); }); // ============================================================================ @@ -1644,155 +1492,3 @@ describe('normalizeEffectExtent', () => { expect(result).toEqual({ left: 0, top: 0, right: 0, bottom: 10 }); }); }); - -// ============================================================================ -// OOXML Utilities Tests -// ============================================================================ - -import { - asOoxmlElement, - findOoxmlChild, - getOoxmlAttribute, - parseOoxmlNumber, - hasOwnProperty, - type OoxmlElement, -} from './utilities.js'; - -describe('OOXML Utilities', () => { - describe('asOoxmlElement', () => { - it('returns undefined for null/undefined', () => { - expect(asOoxmlElement(null)).toBeUndefined(); - expect(asOoxmlElement(undefined)).toBeUndefined(); - }); - - it('returns undefined for non-objects', () => { - expect(asOoxmlElement('string')).toBeUndefined(); - expect(asOoxmlElement(42)).toBeUndefined(); - }); - - it('returns undefined for empty objects', () => { - expect(asOoxmlElement({})).toBeUndefined(); - }); - - it('returns element with name property', () => { - const element = { name: 'w:p' }; - expect(asOoxmlElement(element)).toBe(element); - }); - - it('returns element with attributes property', () => { - const element = { attributes: { 'w:val': '240' } }; - expect(asOoxmlElement(element)).toBe(element); - }); - - it('returns element with elements property', () => { - const element = { elements: [] }; - expect(asOoxmlElement(element)).toBe(element); - }); - - it('returns full OOXML element', () => { - const element: OoxmlElement = { - name: 'w:pPr', - attributes: { 'w:rsidR': '00A77B3E' }, - elements: [{ name: 'w:spacing', attributes: { 'w:before': '240' } }], - }; - expect(asOoxmlElement(element)).toBe(element); - }); - }); - - describe('findOoxmlChild', () => { - it('returns undefined for undefined parent', () => { - expect(findOoxmlChild(undefined, 'w:spacing')).toBeUndefined(); - }); - - it('returns undefined for parent without elements', () => { - expect(findOoxmlChild({ name: 'w:pPr' }, 'w:spacing')).toBeUndefined(); - }); - - it('returns undefined when child not found', () => { - const parent: OoxmlElement = { - name: 'w:pPr', - elements: [{ name: 'w:jc' }], - }; - expect(findOoxmlChild(parent, 'w:spacing')).toBeUndefined(); - }); - - it('finds child element by name', () => { - const spacingEl: OoxmlElement = { name: 'w:spacing', attributes: { 'w:before': '240' } }; - const parent: OoxmlElement = { - name: 'w:pPr', - elements: [{ name: 'w:jc' }, spacingEl, { name: 'w:ind' }], - }; - expect(findOoxmlChild(parent, 'w:spacing')).toBe(spacingEl); - }); - }); - - describe('getOoxmlAttribute', () => { - it('returns undefined for undefined element', () => { - expect(getOoxmlAttribute(undefined, 'w:before')).toBeUndefined(); - }); - - it('returns undefined for element without attributes', () => { - expect(getOoxmlAttribute({ name: 'w:spacing' }, 'w:before')).toBeUndefined(); - }); - - it('gets attribute with w: prefix', () => { - const element: OoxmlElement = { name: 'w:spacing', attributes: { 'w:before': '240' } }; - expect(getOoxmlAttribute(element, 'w:before')).toBe('240'); - }); - - it('gets attribute without prefix when prefixed key requested', () => { - const element: OoxmlElement = { name: 'w:spacing', attributes: { before: '240' } }; - expect(getOoxmlAttribute(element, 'w:before')).toBe('240'); - }); - - it('gets attribute with prefix when unprefixed key requested', () => { - const element: OoxmlElement = { name: 'w:spacing', attributes: { 'w:before': '240' } }; - expect(getOoxmlAttribute(element, 'before')).toBe('240'); - }); - }); - - describe('parseOoxmlNumber', () => { - it('returns undefined for null/undefined', () => { - expect(parseOoxmlNumber(null)).toBeUndefined(); - expect(parseOoxmlNumber(undefined)).toBeUndefined(); - }); - - it('returns number for number input', () => { - expect(parseOoxmlNumber(240)).toBe(240); - expect(parseOoxmlNumber(0)).toBe(0); - expect(parseOoxmlNumber(-100)).toBe(-100); - }); - - it('parses string to integer', () => { - expect(parseOoxmlNumber('240')).toBe(240); - expect(parseOoxmlNumber('0')).toBe(0); - expect(parseOoxmlNumber('-100')).toBe(-100); - }); - - it('returns undefined for non-numeric strings', () => { - expect(parseOoxmlNumber('abc')).toBeUndefined(); - expect(parseOoxmlNumber('')).toBeUndefined(); - }); - - it('returns undefined for non-finite results', () => { - expect(parseOoxmlNumber(NaN)).toBeUndefined(); - expect(parseOoxmlNumber(Infinity)).toBeUndefined(); - }); - }); - - describe('hasOwnProperty', () => { - it('returns true for own properties', () => { - expect(hasOwnProperty({ a: 1 }, 'a')).toBe(true); - expect(hasOwnProperty({ a: undefined }, 'a')).toBe(true); - }); - - it('returns false for missing properties', () => { - expect(hasOwnProperty({ a: 1 }, 'b')).toBe(false); - }); - - it('returns false for inherited properties', () => { - expect(hasOwnProperty({ a: 1 }, 'toString')).toBe(false); - expect(hasOwnProperty({ a: 1 }, 'hasOwnProperty')).toBe(false); - }); - }); -}); diff --git a/packages/layout-engine/pm-adapter/src/utilities.ts b/packages/layout-engine/pm-adapter/src/utilities.ts index b073ea2c9e..ae7411d983 100644 --- a/packages/layout-engine/pm-adapter/src/utilities.ts +++ b/packages/layout-engine/pm-adapter/src/utilities.ts @@ -80,52 +80,6 @@ export const ptToPx = (pt?: number | null): number | undefined => { return pt * PX_PER_PT; }; -/** - * Converts a value from pixels to points. - * - * @param px - The value in pixels to convert (optional, nullable) - * @returns The equivalent value in points, or undefined if input is null/undefined/not finite - * - * @example - * ```typescript - * const points = pxToPt(16); // 12pt (16px at 96 DPI) - * pxToPt(null); // undefined - * pxToPt(Infinity); // undefined - * ``` - */ -export const pxToPt = (px?: number | null): number | undefined => { - if (px == null || !Number.isFinite(px)) return undefined; - return px / PX_PER_PT; -}; - -/** - * Converts paragraph indent values from twips to pixels. - * - * Takes an indent object with potentially four properties (left, right, firstLine, hanging) - * and converts any finite numeric values from twips to pixels. - * - * @param indent - The paragraph indent object with values in twips (optional, nullable) - * @returns A new indent object with values in pixels, or undefined if no valid values exist - * - * @example - * ```typescript - * const pxIndent = convertIndentTwipsToPx({ left: 1440, firstLine: 720 }); - * // { left: 96, firstLine: 48 } - * - * convertIndentTwipsToPx(null); // undefined - * convertIndentTwipsToPx({}); // undefined (no valid properties) - * ``` - */ -export const convertIndentTwipsToPx = (indent?: ParagraphIndent | null): ParagraphIndent | undefined => { - if (!indent) return undefined; - const result: ParagraphIndent = {}; - if (isFiniteNumber(indent.left)) result.left = twipsToPx(indent.left); - if (isFiniteNumber(indent.right)) result.right = twipsToPx(indent.right); - if (isFiniteNumber(indent.firstLine)) result.firstLine = twipsToPx(indent.firstLine); - if (isFiniteNumber(indent.hanging)) result.hanging = twipsToPx(indent.hanging); - return Object.keys(result).length ? result : undefined; -}; - // ============================================================================ // Type Guards // ============================================================================ @@ -223,52 +177,6 @@ export const pickNumber = (value: unknown): number | undefined => { return undefined; }; -/** - * Validates and normalizes a decimal separator character. - * - * Only accepts '.' or ',' as valid decimal separators. - * - * @param value - The value to validate as a decimal separator - * @returns The normalized separator ('.' or ','), or undefined if invalid - * - * @example - * ```typescript - * pickDecimalSeparator("."); // "." - * pickDecimalSeparator(","); // "," - * pickDecimalSeparator(" . "); // "." - * pickDecimalSeparator(";"); // undefined - * pickDecimalSeparator(123); // undefined - * ``` - */ -export const pickDecimalSeparator = (value: unknown): string | undefined => { - if (typeof value !== 'string') return undefined; - const normalized = value.trim(); - if (normalized === '.' || normalized === ',') return normalized; - return undefined; -}; - -/** - * Extracts and normalizes a language code string. - * - * Trims whitespace and converts to lowercase. Returns undefined for empty strings. - * - * @param value - The language code to normalize - * @returns The normalized language code, or undefined if invalid or empty - * - * @example - * ```typescript - * pickLang("EN-US"); // "en-us" - * pickLang(" fr "); // "fr" - * pickLang(""); // undefined - * pickLang(123); // undefined - * ``` - */ -export const pickLang = (value: unknown): string | undefined => { - if (typeof value !== 'string') return undefined; - const normalized = value.trim().toLowerCase(); - return normalized || undefined; -}; - /** * Normalizes a color string, ensuring it has a leading '#' symbol. * @@ -465,78 +373,6 @@ export const toBoolean = (value: unknown): boolean | undefined => { return undefined; }; -/** - * Checks if a value is explicitly truthy according to specific patterns. - * - * Unlike coerceBoolean which returns undefined for unrecognized values, this - * function always returns a definite boolean. It returns true ONLY for explicitly - * truthy values, and false for everything else (including unrecognized values). - * - * Use this when you need a definite boolean answer and want to treat unknown - * values as false rather than undefined. - * - * Recognized truthy values: true, 1, 'true', '1', 'on' - * - * @param value - The value to check for truthiness - * @returns True if the value matches truthy patterns, false otherwise - * - * @example - * ```typescript - * isTruthy(true); // true - * isTruthy(1); // true - * isTruthy("true"); // true - * isTruthy("on"); // true - * isTruthy(false); // false - * isTruthy(0); // false - * isTruthy("yes"); // false (not in recognized patterns) - * isTruthy("maybe"); // false - * isTruthy(null); // false - * ``` - */ -export const isTruthy = (value: unknown): boolean => { - if (value === true || value === 1) return true; - if (typeof value === 'string') { - const normalized = value.toLowerCase(); - if (normalized === 'true' || normalized === '1' || normalized === 'on') { - return true; - } - } - return false; -}; - -/** - * Checks if a value is explicitly false according to specific patterns. - * - * Similar to isTruthy, this always returns a definite boolean. It returns true - * ONLY when the value explicitly indicates false, not for unrecognized values. - * - * Recognized falsy values: false, 0, 'false', '0', 'off' - * - * @param value - The value to check for explicit falseness - * @returns True if the value matches explicit false patterns, false otherwise - * - * @example - * ```typescript - * isExplicitFalse(false); // true - * isExplicitFalse(0); // true - * isExplicitFalse("false"); // true - * isExplicitFalse("off"); // true - * isExplicitFalse(true); // false - * isExplicitFalse(1); // false - * isExplicitFalse("no"); // false (not in recognized patterns) - * isExplicitFalse("maybe"); // false - * isExplicitFalse(null); // false - * ``` - */ -export const isExplicitFalse = (value: unknown): boolean => { - if (value === false || value === 0) return true; - if (typeof value === 'string') { - const normalized = value.toLowerCase(); - return normalized === 'false' || normalized === '0' || normalized === 'off'; - } - return false; -}; - // ============================================================================ // Box Spacing Utilities // ============================================================================ @@ -1605,145 +1441,6 @@ export const OOXML_Z_INDEX_BASE = 251658240; // OOXML Element Utilities // ============================================================================ -/** - * Represents an OOXML XML element structure. - * - * Used for parsing and traversing OOXML document elements that come from - * parsed XML structures (e.g., from xml-js or similar parsers). - * - * @example - * ```typescript - * const element: OoxmlElement = { - * name: 'w:p', - * attributes: { 'w:rsidR': '00A77B3E' }, - * elements: [ - * { name: 'w:pPr', elements: [...] }, - * { name: 'w:r', elements: [...] } - * ] - * }; - * ``` - */ -export type OoxmlElement = { - /** The element name (e.g., 'w:p', 'w:r', 'w:spacing') */ - name?: string; - /** Element attributes as key-value pairs */ - attributes?: Record; - /** Child elements */ - elements?: OoxmlElement[]; -}; - -/** - * Safely converts an unknown value to an OoxmlElement if it has the expected structure. - * - * Validates that the value is a non-null object with at least one of the - * expected OoxmlElement properties (name, attributes, or elements). - * - * @param value - The value to convert - * @returns The value as an OoxmlElement, or undefined if invalid - * - * @example - * ```typescript - * asOoxmlElement({ name: 'w:p' }); // { name: 'w:p' } - * asOoxmlElement({ elements: [] }); // { elements: [] } - * asOoxmlElement(null); // undefined - * asOoxmlElement('string'); // undefined - * asOoxmlElement({}); // undefined (no recognized properties) - * ``` - */ -export const asOoxmlElement = (value: unknown): OoxmlElement | undefined => { - if (!value || typeof value !== 'object') return undefined; - const element = value as OoxmlElement; - if (element.name == null && element.attributes == null && element.elements == null) return undefined; - return element; -}; - -/** - * Finds a direct child element by name within an OOXML element. - * - * Searches only immediate children, not nested descendants. - * - * @param parent - The parent element to search within - * @param name - The element name to find (e.g., 'w:pPr', 'w:spacing') - * @returns The first matching child element, or undefined if not found - * - * @example - * ```typescript - * const pPr = findOoxmlChild(paragraph, 'w:pPr'); - * const spacing = findOoxmlChild(pPr, 'w:spacing'); - * ``` - */ -export const findOoxmlChild = (parent: OoxmlElement | undefined, name: string): OoxmlElement | undefined => { - return parent?.elements?.find((child) => child?.name === name); -}; - -/** - * Gets an attribute value from an OOXML element, handling both prefixed and unprefixed keys. - * - * OOXML attributes may be stored with or without the 'w:' namespace prefix. - * This function checks both variants to ensure robust attribute retrieval. - * - * @param element - The element to get the attribute from - * @param key - The attribute key (can be with or without 'w:' prefix) - * @returns The attribute value, or undefined if not found - * - * @example - * ```typescript - * // Element: { attributes: { 'w:before': '240' } } - * getOoxmlAttribute(element, 'w:before'); // '240' - * getOoxmlAttribute(element, 'before'); // '240' - * - * // Element: { attributes: { 'before': '240' } } - * getOoxmlAttribute(element, 'w:before'); // '240' - * ``` - */ -export const getOoxmlAttribute = (element: OoxmlElement | undefined, key: string): unknown => { - if (!element?.attributes) return undefined; - const attrs = element.attributes as Record; - return attrs[key] ?? attrs[key.startsWith('w:') ? key.slice(2) : `w:${key}`]; -}; - -/** - * Parses a value as an integer number, handling both number and string inputs. - * - * Used for parsing OOXML attribute values which may be stored as strings. - * - * @param value - The value to parse (number, string, or other) - * @returns The parsed integer, or undefined if parsing fails or value is null/undefined - * - * @example - * ```typescript - * parseOoxmlNumber(240); // 240 - * parseOoxmlNumber('240'); // 240 - * parseOoxmlNumber('invalid'); // undefined - * parseOoxmlNumber(null); // undefined - * ``` - */ -export const parseOoxmlNumber = (value: unknown): number | undefined => { - if (value == null) return undefined; - const num = typeof value === 'number' ? value : Number.parseInt(String(value), 10); - return Number.isFinite(num) ? num : undefined; -}; - -/** - * Checks if an object has its own property (not inherited). - * - * Uses Object.prototype.hasOwnProperty.call for safety with objects - * that may have a null prototype or override hasOwnProperty. - * - * @param obj - The object to check - * @param key - The property key to check for - * @returns True if the object has its own property with the given key - * - * @example - * ```typescript - * hasOwnProperty({ a: 1 }, 'a'); // true - * hasOwnProperty({ a: 1 }, 'b'); // false - * hasOwnProperty({ a: 1 }, 'toString'); // false (inherited) - * ``` - */ -export const hasOwnProperty = (obj: Record, key: string): boolean => - Object.prototype.hasOwnProperty.call(obj, key); - /** * Normalizes z-index from OOXML relativeHeight value. * diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 98e7bdfcc4..2ed8586aec 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -2792,8 +2792,6 @@ export class PresentationEditor extends EventEmitter { converterContext = converter ? { docx: converter.convertedXml, - numbering: converter.numbering, - linkedStyles: converter.linkedStyles, ...(Object.keys(footnoteNumberById).length ? { footnoteNumberById } : {}), translatedLinkedStyles: converter.translatedLinkedStyles, translatedNumbering: converter.translatedNumbering,