diff --git a/.gitignore b/.gitignore index 5935635a41..c89977e3bc 100644 --- a/.gitignore +++ b/.gitignore @@ -34,8 +34,6 @@ lerna-debug.log* .vitepress/.cache .vitepress/dist -packages/super-editor/src/tests/data/*/ - packages/word-layout/tests/**/*.js packages/word-layout/tests/**/*.d.ts packages/word-layout/tests/**/*.map diff --git a/packages/layout-engine/pm-adapter/src/converter-context.ts b/packages/layout-engine/pm-adapter/src/converter-context.ts index c68f9da87e..d49a0b72fe 100644 --- a/packages/layout-engine/pm-adapter/src/converter-context.ts +++ b/packages/layout-engine/pm-adapter/src/converter-context.ts @@ -10,6 +10,7 @@ import type { ParagraphSpacing } from '@superdoc/contracts'; import type { NumberingProperties, StylesDocumentProperties, TableInfo } from '@superdoc/style-engine/ooxml'; +import { SuperConverter } from '@superdoc/super-editor'; /** * Paragraph properties from a table style that should be applied to @@ -44,6 +45,8 @@ export type ConverterContext = { * contrast with the cell background per WCAG guidelines. */ backgroundColor?: string; + + numbering?: SuperConverter['numbering']; }; /** diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index dd6197a551..ad1fa2eb24 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -55,6 +55,7 @@ import { ProseMirrorRenderer } from './renderers/ProseMirrorRenderer.js'; import { BLANK_DOCX_DATA_URI } from './blank-docx.js'; import { getArrayBufferFromUrl } from '@core/super-converter/helpers.js'; import { Telemetry, COMMUNITY_LICENSE_KEY } from '@superdoc/common'; +import { OpenXmlNode } from '@converter/v2/types'; declare const __APP_VERSION__: string; declare const version: string | undefined; @@ -151,6 +152,25 @@ export interface SaveOptions { */ export type ExportOptions = SaveOptions; +type ExportDocxProps = { + isFinalDoc?: boolean; + commentsType?: string; + exportJsonOnly?: boolean; + exportXmlOnly?: boolean; + comments?: Comment[]; + getUpdatedDocs?: boolean; + fieldsHighlightColor?: string | null; + compression?: 'DEFLATE' | 'STORE'; +}; + +type ExportDocxResult = TProps extends { exportJsonOnly: true } + ? OpenXmlNode + : TProps extends { exportXmlOnly: true } + ? string + : TProps extends { getUpdatedDocs: true } + ? Record + : Blob | Buffer; + /** * Main editor class that manages document state, extensions, and user interactions */ @@ -2510,7 +2530,7 @@ export class Editor extends EventEmitter { /** * Export the editor document to DOCX. */ - async exportDocx({ + async exportDocx({ isFinalDoc = false, commentsType = 'external', exportJsonOnly = false, @@ -2519,16 +2539,7 @@ export class Editor extends EventEmitter { getUpdatedDocs = false, fieldsHighlightColor = null, compression, - }: { - isFinalDoc?: boolean; - commentsType?: string; - exportJsonOnly?: boolean; - exportXmlOnly?: boolean; - comments?: Comment[]; - getUpdatedDocs?: boolean; - fieldsHighlightColor?: string | null; - compression?: 'DEFLATE' | 'STORE'; - } = {}): Promise | ProseMirrorJSON | string | undefined> { + }: ExportDocxProps = {}): Promise | undefined> { try { // Use provided comments, or fall back to imported comments from converter const effectiveComments = comments ?? this.converter.comments ?? []; @@ -2561,16 +2572,22 @@ export class Editor extends EventEmitter { this.#validateDocumentExport(); - if (exportXmlOnly || exportJsonOnly) return documentXml; + if (exportXmlOnly || exportJsonOnly) { + // Ideally this cast wouldn't be required; this would likely be solved by + // https://github.com/microsoft/TypeScript/pull/61359 + return documentXml as ExportDocxResult; + } - const customXml = this.converter.schemaToXml(this.converter.convertedXml['docProps/custom.xml'].elements[0]); - const styles = this.converter.schemaToXml(this.converter.convertedXml['word/styles.xml'].elements[0]); + const customXml = this.converter.schemaToXml(this.converter.convertedXml['docProps/custom.xml']?.elements?.[0]); + const styles = this.converter.schemaToXml(this.converter.convertedXml['word/styles.xml']?.elements?.[0]); const hasCustomSettings = !!this.converter.convertedXml['word/settings.xml']?.elements?.length; const customSettings = hasCustomSettings ? this.converter.schemaToXml(this.converter.convertedXml['word/settings.xml']?.elements?.[0]) : null; - const rels = this.converter.schemaToXml(this.converter.convertedXml['word/_rels/document.xml.rels'].elements[0]); + const rels = this.converter.schemaToXml( + this.converter.convertedXml['word/_rels/document.xml.rels']?.elements?.[0], + ); const footnotesData = this.converter.convertedXml['word/footnotes.xml']; const footnotesXml = footnotesData?.elements?.[0] ? this.converter.schemaToXml(footnotesData.elements[0]) : null; const footnotesRelsData = this.converter.convertedXml['word/_rels/footnotes.xml.rels']; @@ -2590,7 +2607,7 @@ export class Editor extends EventEmitter { }); const numberingData = this.converter.convertedXml['word/numbering.xml']; - const numbering = this.converter.schemaToXml(numberingData.elements[0]); + const numbering = this.converter.schemaToXml(numberingData?.elements?.[0]); // Export core.xml (contains dcterms:created timestamp) const coreXmlData = this.converter.convertedXml['docProps/core.xml']; @@ -2620,7 +2637,7 @@ export class Editor extends EventEmitter { } if (preparedComments.length) { - const commentsXml = this.converter.schemaToXml(this.converter.convertedXml['word/comments.xml'].elements[0]); + const commentsXml = this.converter.schemaToXml(this.converter.convertedXml['word/comments.xml']?.elements?.[0]); updatedDocs['word/comments.xml'] = String(commentsXml); const commentsExtended = this.converter.convertedXml['word/commentsExtended.xml']; @@ -2653,7 +2670,9 @@ export class Editor extends EventEmitter { true, updatedDocs, ); - return updatedDocs; + // Ideally this cast wouldn't be required; this would likely be solved by + // https://github.com/microsoft/TypeScript/pull/61359 + return updatedDocs as ExportDocxResult; } const result = await zipper.updateZip({ @@ -3137,9 +3156,9 @@ export class Editor extends EventEmitter { } if (type === 'json') { - return this.converter.convertedXml[name].elements[0] || null; + return this.converter.convertedXml[name]?.elements?.[0] || null; } - return this.converter.schemaToXml(this.converter.convertedXml[name].elements[0]); + return this.converter.schemaToXml(this.converter.convertedXml[name]?.elements?.[0]); } /** diff --git a/packages/super-editor/src/core/header-footer/HeaderFooterRegistry.ts b/packages/super-editor/src/core/header-footer/HeaderFooterRegistry.ts index e939d16344..a27f58d33e 100644 --- a/packages/super-editor/src/core/header-footer/HeaderFooterRegistry.ts +++ b/packages/super-editor/src/core/header-footer/HeaderFooterRegistry.ts @@ -6,33 +6,19 @@ import type { Editor } from '@core/Editor.js'; import { EventEmitter } from '@core/EventEmitter.js'; import { createHeaderFooterEditor, onHeaderFooterDataUpdate } from '@extensions/pagination/pagination-helpers.js'; import type { ConverterContext } from '@superdoc/pm-adapter/converter-context.js'; +import { SuperConverter } from '@converter/SuperConverter'; const HEADER_FOOTER_VARIANTS = ['default', 'first', 'even', 'odd'] as const; const DEFAULT_HEADER_FOOTER_HEIGHT = 100; const EDITOR_READY_TIMEOUT_MS = 5000; const MAX_CACHED_EDITORS_LIMIT = 100; -type MinimalConverterContext = { - docx?: Record; - numbering?: { - definitions?: Record; - abstracts?: Record; - }; - linkedStyles?: Array<{ - id: string; - definition?: { - styles?: Record; - attrs?: Record; - }; - }>; -}; - /** * Extended Editor interface that includes the converter property. * Used for type-safe access to header/footer data stored in the converter. */ interface EditorWithConverter extends Editor { - converter: HeaderFooterCollections; + converter: Required['converter']; // TODO: Should `converter` property of Editor be marked as optional (`?`) rather than definite-assignment (`!`)? } export type HeaderFooterKind = 'header' | 'footer'; @@ -782,7 +768,7 @@ export class HeaderFooterEditorManager extends EventEmitter { if (!this.#hasConverter(this.#editor)) { return; } - const converter = this.#editor.converter as Record; + const converter = this.#editor.converter; if (!converter) return; const targetKey = descriptor.kind === 'header' ? 'headerEditors' : 'footerEditors'; @@ -817,7 +803,7 @@ export class HeaderFooterEditorManager extends EventEmitter { if (!this.#hasConverter(this.#editor)) { return; } - const converter = this.#editor.converter as Record; + const converter = this.#editor.converter; if (!converter) return; const targetKey = descriptor.kind === 'header' ? 'headerEditors' : 'footerEditors'; @@ -1176,7 +1162,7 @@ export class HeaderFooterLayoutAdapter { if (!('converter' in rootEditor)) { return undefined; } - const converter = (rootEditor as EditorWithConverter).converter as Record | undefined; + const converter = rootEditor.converter as SuperConverter | undefined; if (!converter) return undefined; const context: ConverterContext = { @@ -1184,7 +1170,7 @@ export class HeaderFooterLayoutAdapter { numbering: converter.numbering, translatedLinkedStyles: converter.translatedLinkedStyles, translatedNumbering: converter.translatedNumbering, - } as ConverterContext; + }; return context; } diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.d.ts b/packages/super-editor/src/core/super-converter/SuperConverter.d.ts index 74ef1a21d8..c0e90a4234 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.d.ts +++ b/packages/super-editor/src/core/super-converter/SuperConverter.d.ts @@ -1,7 +1,446 @@ export class SuperConverter { - constructor(...args: any[]); - static getStoredSuperdocVersion(...args: any[]): any; - static setStoredSuperdocVersion(...args: any[]): void; - static extractDocumentGuid(...args: any[]): string | null; - [key: string]: any; + static allowedElements: Readonly<{ + 'w:document': 'doc'; + 'w:body': 'body'; + 'w:p': 'paragraph'; + 'w:r': 'run'; + 'w:t': 'text'; + 'w:delText': 'text'; + 'w:br': 'lineBreak'; + 'w:tbl': 'table'; + 'w:tr': 'tableRow'; + 'w:tc': 'tableCell'; + 'w:drawing': 'drawing'; + 'w:bookmarkStart': 'bookmarkStart'; + 'w:sectPr': 'sectionProperties'; + 'w:rPr': 'runProperties'; + 'w:commentRangeStart': 'commentRangeStart'; + 'w:commentRangeEnd': 'commentRangeEnd'; + 'w:commentReference': 'commentReference'; + }>; + static markTypes: ( + | { + name: string; + type: string; + property: string; + mark?: undefined; + } + | { + name: string; + type: string; + property?: undefined; + mark?: undefined; + } + | { + name: string; + type: string; + mark: string; + property: string; + } + )[]; + static propertyTypes: Readonly<{ + 'w:pPr': 'paragraphProperties'; + 'w:rPr': 'runProperties'; + 'w:sectPr': 'sectionProperties'; + 'w:numPr': 'numberingProperties'; + 'w:tcPr': 'tableCellProperties'; + }>; + static elements: Set; + static getFontTableEntry(docx: any, fontName: any): any; + static getFallbackFromFontTable(docx: any, fontName: any): any; + static toCssFontFamily(fontName: any, docx: any): any; + /** + * Checks if an element name matches the expected local name, with or without namespace prefix. + * This helper supports custom namespace prefixes in DOCX files (e.g., 'op:Properties', 'custom:property'). + * + * @private + * @static + * @param {string|undefined|null} elementName - The element name to check (may include namespace prefix) + * @param {string} expectedLocalName - The expected local name without prefix + * @returns {boolean} True if the element name matches (with or without prefix) + * + * @example + * // Exact match without prefix + * _matchesElementName('Properties', 'Properties') // => true + * + * @example + * // Match with namespace prefix + * _matchesElementName('op:Properties', 'Properties') // => true + * _matchesElementName('custom:property', 'property') // => true + * + * @example + * // No match + * _matchesElementName('SomeOtherElement', 'Properties') // => false + * _matchesElementName(':Properties', 'Properties') // => false (empty prefix) + */ + private static _matchesElementName; + /** + * Extracts the namespace prefix from an element name. + * + * @private + * @static + * @param {string} elementName - The element name (may include namespace prefix, e.g., 'op:Properties') + * @returns {string} The namespace prefix (e.g., 'op') or empty string if no prefix + * + * @example + * _extractNamespacePrefix('op:Properties') // => 'op' + * _extractNamespacePrefix('Properties') // => '' + * _extractNamespacePrefix('custom:property') // => 'custom' + */ + private static _extractNamespacePrefix; + /** + * Generic method to get a stored custom property from docx. + * Supports both standard and custom namespace prefixes (e.g., 'op:Properties', 'custom:property'). + * + * @static + * @param {Array} docx - Array of docx file objects + * @param {string} propertyName - Name of the property to retrieve + * @returns {string|null} The property value or null if not found + * + * Returns null in the following cases: + * - docx array is empty or doesn't contain 'docProps/custom.xml' + * - custom.xml cannot be parsed + * - Properties element is not found (with or without namespace prefix) + * - Property with the specified name is not found + * - Property has malformed structure (missing nested elements or text) + * - Any error occurs during parsing or retrieval + * + * @example + * // Standard property without namespace prefix + * const version = SuperConverter.getStoredCustomProperty(docx, 'SuperdocVersion'); + * // => '1.2.3' + * + * @example + * // Property with namespace prefix (e.g., from Office 365) + * const guid = SuperConverter.getStoredCustomProperty(docx, 'DocumentGuid'); + * // Works with both 'Properties' and 'op:Properties' elements + * // => 'abc-123-def-456' + * + * @example + * // Non-existent property + * const missing = SuperConverter.getStoredCustomProperty(docx, 'NonExistent'); + * // => null + */ + static getStoredCustomProperty(docx: any[], propertyName: string): string | null; + /** + * Generic method to set a stored custom property in docx. + * Supports both standard and custom namespace prefixes (e.g., 'op:Properties', 'custom:property'). + * + * @static + * @param {Object} docx - The docx object to store the property in (converted XML structure) + * @param {string} propertyName - Name of the property + * @param {string|Function} value - Value or function that returns the value + * @param {boolean} preserveExisting - If true, won't overwrite existing values + * @returns {string|null} The stored value, or null if Properties element is not found + * + * @throws {Error} If an error occurs during property setting (logged as warning) + * + * @example + * // Set a new property + * const value = SuperConverter.setStoredCustomProperty(docx, 'MyProperty', 'MyValue'); + * // => 'MyValue' + * + * @example + * // Set a property with a function + * const guid = SuperConverter.setStoredCustomProperty(docx, 'DocumentGuid', () => uuidv4()); + * // => 'abc-123-def-456' + * + * @example + * // Preserve existing value + * SuperConverter.setStoredCustomProperty(docx, 'MyProperty', 'NewValue', true); + * // => 'MyValue' (original value preserved) + * + * @example + * // Works with namespace prefixes + * // If docx has 'op:Properties' and 'op:property' elements, this will handle them correctly + * const version = SuperConverter.setStoredCustomProperty(docx, 'Version', '2.0.0'); + * // => '2.0.0' + */ + static setStoredCustomProperty( + docx: any, + propertyName: string, + value: string | Function, + preserveExisting?: boolean, + ): string | null; + static getStoredSuperdocVersion(docx: any): string; + static setStoredSuperdocVersion(docx?: any, version?: any): string; + /** + * Generate a Word-compatible timestamp (truncated to minute precision like MS Word) + * @returns {string} Timestamp in YYYY-MM-DDTHH:MM:00Z format + */ + static generateWordTimestamp(): string; + /** + * Get document GUID from docx files (static method) + * @static + * @param {Array} docx - Array of docx file objects + * @returns {string|null} The document GUID + */ + static extractDocumentGuid(docx: any[]): string | null; + static getStoredSuperdocId(docx: any): string; + static updateDocumentVersion(docx: any, version: any): string; + constructor(params?: any); + /** @type {StylesDocumentProperties} */ + translatedLinkedStyles: StylesDocumentProperties; + /** @type {import('./types').Numbering} */ + numbering: import('./types').Numbering; + /** @type {import('./types').Numbering} */ + translatedNumbering: import('./types').Numbering; + /** @type {Record} */ + convertedXml: Record; + debug: any; + domEnvironment: { + mockWindow: any; + mockDocument: any; + }; + declaration: any; + documentAttributes: xmljs.Attributes; + docx: any; + media: any; + fonts: any; + addedMedia: {}; + comments: any[]; + footnotes: any[]; + footnoteProperties: any; + inlineDocumentFonts: any[]; + commentThreadingProfile: any; + docHiglightColors: Set; + xml: any; + pageStyles: any; + themeColors: {}; + initialJSON: any; + headers: {}; + headerIds: { + default: any; + even: any; + odd: any; + first: any; + }; + headerEditors: any[]; + footers: {}; + footerIds: { + default: any; + even: any; + odd: any; + first: any; + }; + footerEditors: any[]; + importedBodyHasHeaderRef: boolean; + importedBodyHasFooterRef: boolean; + headerFooterModified: boolean; + linkedStyles: any[]; + json: any; + tagsNotInSchema: string[]; + savedTagsToRestore: any[]; + documentInternalId: any; + fileSource: any; + documentId: any; + documentGuid: any; + documentUniqueIdentifier: string; + documentModified: boolean; + isBlankDoc: any; + /** + * Get the DocxHelpers object that contains utility functions for working with docx files. + * @returns {import('./docx-helpers/docx-helpers.js').DocxHelpers} The DocxHelpers object. + */ + get docxHelpers(): any; + parseFromXml(): void; + /** + * Parses XML content into JSON format while preserving whitespace-only text runs. + * + * This method wraps xml-js's xml2json parser with additional preprocessing to prevent + * the parser from dropping whitespace-only content in and elements. + * This is critical for correctly handling documents that rely on document-level + * xml:space="preserve" rather than per-element attributes, which is common in + * PDF-to-DOCX converted documents. + * + * The whitespace preservation strategy: + * 1. Before parsing, wraps whitespace-only content with [[sdspace]] placeholders + * 2. xml-js parser preserves the placeholder-wrapped text + * 3. During text node processing (t-translator.js), placeholders are removed + * + * @param {string} xml - The XML string to parse + * @returns {Object} The parsed JSON representation of the XML document + * + * @example + * // Handles whitespace-only text runs + * const xml = ' '; + * const result = parseXmlToJson(xml); + * // Result preserves the space: { elements: [{ text: '[[sdspace]] [[sdspace]]' }] } + * + * @example + * // Handles elements with attributes + * const xml = ' text '; + * const result = parseXmlToJson(xml); + * // Preserves content and attributes + * + * @example + * // Handles both w:t and w:delText elements + * const xml = ' '; + * const result = parseXmlToJson(xml); + * // Preserves whitespace in deleted text + */ + parseXmlToJson(xml: string): any; + /** + * Get the dcterms:created timestamp from the already-parsed core.xml + * @returns {string|null} The created timestamp in ISO format, or null if not found + */ + getDocumentCreatedTimestamp(): string | null; + /** + * Set the dcterms:created timestamp in the already-parsed core.xml + * @param {string} timestamp - The timestamp to set (ISO format) + */ + setDocumentCreatedTimestamp(timestamp: string): void; + /** + * Get the permanent document GUID + * @returns {string|null} The document GUID (only for modified documents) + */ + getDocumentGuid(): string | null; + /** + * Get the SuperDoc version for this converter instance + * @returns {string|null} The SuperDoc version or null if not available + */ + getSuperdocVersion(): string | null; + /** + * Resolve existing document GUID (synchronous) + * For new files: reads existing GUID and sets fresh timestamp + * For imported files: reads existing GUIDs only + */ + resolveDocumentGuid(): void; + /** + * Get Microsoft's docId from settings.xml (READ ONLY) + */ + getMicrosoftDocId(): any; + /** + * Get document unique identifier (async) + * + * For blank documents (isBlankDoc: true): + * - GUID and timestamp already set in resolveDocumentGuid() + * - Returns identifierHash(guid|timestamp) + * + * For imported files (isBlankDoc: false): + * - If both documentGuid and dcterms:created exist: returns identifierHash + * - Otherwise: returns contentHash and generates missing metadata for future exports + * + * @returns {Promise} Document unique identifier + */ + getDocumentIdentifier(): Promise; + /** + * Promote to GUID on first edit (for documents that didn't have one) + */ + promoteToGuid(): any; + /** + * @return {Record} + */ + getDocumentDefaultStyles(): Record; + getDocumentFonts(): any[]; + getFontFaceImportString(): { + styleString: string; + fontsImported: any[]; + }; + getDocumentInternalId(): void; + createDocumentIdElement(): { + type: string; + name: string; + attributes: { + 'w15:val': string; + }; + }; + getThemeInfo(themeName: any): + | { + typeface?: undefined; + panose?: undefined; + } + | { + typeface: string | number; + panose: string | number; + }; + getSchema(editor: any): { + type: string; + content: any[]; + attrs: { + bodySectPr?: any; + attributes: any; + }; + }; + schemaToXml(data: any, debug?: boolean): string; + exportToDocx( + jsonData: any, + editorSchema: any, + documentMedia: any, + isFinalDoc: boolean, + commentsExportType: any, + comments: any[], + editor: any, + exportJsonOnly: boolean, + fieldsHighlightColor: any, + ): Promise; + exportToXmlJson({ + data, + editorSchema, + comments, + commentDefinitions, + commentsExportType, + isFinalDoc, + editor, + isHeaderFooter, + fieldsHighlightColor, + }: { + data: any; + editorSchema: any; + comments: any; + commentDefinitions: any; + commentsExportType?: string; + isFinalDoc?: boolean; + editor: any; + isHeaderFooter?: boolean; + fieldsHighlightColor?: any; + }): { + result: import('./v2/types').OpenXmlNode; + params: import('./exporter').ExportParams; + }; + /** + * Creates a default empty header for the specified variant. + * + * This method programmatically creates a new header section with an empty ProseMirror + * document. The header is added to the converter's data structures and will be included + * in subsequent DOCX exports. + * + * @param {('default' | 'first' | 'even' | 'odd')} variant - The header variant to create + * @returns {string} The relationship ID of the created header + * + * @throws {Error} If variant is invalid or header already exists for this variant + * + * @example + * ```javascript + * const headerId = converter.createDefaultHeader('default'); + * // headerId: 'rId-header-default' + * // converter.headers['rId-header-default'] contains empty PM doc + * // converter.headerIds.default === 'rId-header-default' + * ``` + */ + createDefaultHeader(variant?: 'default' | 'first' | 'even' | 'odd'): string; + /** + * Creates a default empty footer for the specified variant. + * + * This method programmatically creates a new footer section with an empty ProseMirror + * document. The footer is added to the converter's data structures and will be included + * in subsequent DOCX exports. + * + * @param {('default' | 'first' | 'even' | 'odd')} variant - The footer variant to create + * @returns {string} The relationship ID of the created footer + * + * @throws {Error} If variant is invalid or footer already exists for this variant + * + * @example + * ```javascript + * const footerId = converter.createDefaultFooter('default'); + * // footerId: 'rId-footer-default' + * // converter.footers['rId-footer-default'] contains empty PM doc + * // converter.footerIds.default === 'rId-footer-default' + * ``` + */ + createDefaultFooter(variant?: 'default' | 'first' | 'even' | 'odd'): string; + #private; } +import * as xmljs from 'xml-js'; +//# sourceMappingURL=SuperConverter.d.ts.map diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index 57b4f6b4db..6f9e181456 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -70,6 +70,18 @@ const collectRunDefaultProperties = ( }; class SuperConverter { + /** @type {StylesDocumentProperties} */ + translatedLinkedStyles; + + /** @type {import('./types').Numbering} */ + numbering; + + /** @type {import('./types').Numbering} */ + translatedNumbering; + + /** @type {Record} */ + convertedXml; + static allowedElements = Object.freeze({ 'w:document': 'doc', 'w:body': 'body', @@ -853,6 +865,9 @@ class SuperConverter { return this.documentGuid; } + /** + * @return {Record} + */ getDocumentDefaultStyles() { const styles = this.convertedXml['word/styles.xml']; const styleRoot = styles?.elements?.[0]; diff --git a/packages/super-editor/src/core/super-converter/exporter.js b/packages/super-editor/src/core/super-converter/exporter.ts similarity index 83% rename from packages/super-editor/src/core/super-converter/exporter.js rename to packages/super-editor/src/core/super-converter/exporter.ts index 685f44d313..b0f70aa1d9 100644 --- a/packages/super-editor/src/core/super-converter/exporter.js +++ b/packages/super-editor/src/core/super-converter/exporter.ts @@ -27,11 +27,14 @@ import { translator as sdIndexTranslator } from '@converter/v3/handlers/sd/index import { translator as sdIndexEntryTranslator } from '@converter/v3/handlers/sd/indexEntry'; import { translator as sdAutoPageNumberTranslator } from '@converter/v3/handlers/sd/autoPageNumber'; import { translator as sdTotalPageNumberTranslator } from '@converter/v3/handlers/sd/totalPageNumber'; -import { translator as pictTranslator } from './v3/handlers/w/pict/pict-translator'; +import { translator as wPictNodeTranslator } from './v3/handlers/w/pict/pict-translator'; import { translateVectorShape, translateShapeGroup } from '@converter/v3/handlers/wp/helpers/decode-image-node-helpers'; import { translator as wTextTranslator } from '@converter/v3/handlers/w/t'; import { translator as wFootnoteReferenceTranslator } from './v3/handlers/w/footnoteReference/footnoteReference-translator.js'; import { carbonCopy } from '@core/utilities/carbonCopy.js'; +import type { NodeTranslator, SCDecoderConfig } from '@translator'; +import type { OpenXmlNode } from '@converter/v2/types'; +import type { Editor } from '@core/Editor'; const DEFAULT_SECTION_PROPS_TWIPS = Object.freeze({ pageSize: Object.freeze({ width: '12240', height: '15840' }), @@ -46,7 +49,7 @@ const DEFAULT_SECTION_PROPS_TWIPS = Object.freeze({ }), }); -export const ensureSectionLayoutDefaults = (sectPr, converter) => { +export const ensureSectionLayoutDefaults = (sectPr, converter): OpenXmlNode => { if (!sectPr) { return { type: 'element', @@ -106,55 +109,52 @@ export const isLineBreakOnlyRun = (node) => { return runContent.every((child) => child?.type === 'lineBreak' || child?.type === 'hardBreak'); }; -/** - * @typedef {Object} ExportParams - * @property {Object} node JSON node to translate (from PM schema) - * @property {Object} [bodyNode] The stored body node to restore, if available - * @property {Object[]} [relationships] The relationships to add to the document - * @property {Object} [extraParams] The extra params from NodeTranslator - */ +export type ExportParams = SCDecoderConfig & { + bodyNode?: OpenXmlNode; + converter?: SuperConverter; + editor: Editor; + isHeaderFooter?: boolean; +}; -/** - * @typedef {Object} SchemaNode - * @property {string} type The name of this node from the prose mirror schema - * @property {Array} content The child nodes - * @property {Object} attrs The node attributes - * / +/** Key value pairs representing the node attributes from prose mirror */ +type SchemaAttributes = Record; -/** - * @typedef {Object} XmlReadyNode - * @property {string} name The XML tag name - * @property {Array} elements The child nodes - * @property {Object} [attributes] The node attributes - */ +/** Key value pairs representing the node attributes to export to XML format */ +type XmlAttributes = Record; -/** - * @typedef {Object.} SchemaAttributes - * Key value pairs representing the node attributes from prose mirror - */ +type MarkType = { + /** The mark type */ + type: string; + /** Any attributes for this mark */ + attrs: Record; +}; -/** - * @typedef {Object.} XmlAttributes - * Key value pairs representing the node attributes to export to XML format - */ +type Router = { + [k: string]: + | NodeTranslator + | NodeTranslator[] + | ((params: ExportParams) => OpenXmlNode | null | [OpenXmlNode, ExportParams]); +}; -/** - * @typedef {Object} MarkType - * @property {string} type The mark type - * @property {Object} attrs Any attributes for this mark - */ +export function exportSchemaToJson(params: ExportParams & { node: { type: 'doc' } }): [OpenXmlNode, ExportParams]; +export function exportSchemaToJson(params: ExportParams & { node: { type: 'body' } }): OpenXmlNode; +export function exportSchemaToJson( + params: ExportParams & { node: { type: Exclude } }, +): OpenXmlNode | null; /** * Main export function. It expects the prose mirror data as JSON (ie: a doc node) * - * @param {ExportParams} params - The parameters object, containing a node and possibly a body node - * @returns {XmlReadyNode} The complete document node in XML-ready format + * @param params - The parameters object, containing a node and possibly a body node + * @returns - The complete document node in XML-ready format */ -export function exportSchemaToJson(params) { +export function exportSchemaToJson( + params: ExportParams, +): OpenXmlNode | OpenXmlNode[] | null | [OpenXmlNode, ExportParams] { const { type } = params.node || {}; // Node handlers for each node type that we can export - const router = { + const router: Router = { doc: translateDocumentNode, body: translateBodyNode, heading: translateHeadingNode, @@ -170,17 +170,17 @@ export function exportSchemaToJson(params) { bookmarkEnd: wBookmarkEndTranslator, fieldAnnotation: wSdtNodeTranslator, tab: wTabNodeTranslator, - image: wDrawingNodeTranslator, + image: [wDrawingNodeTranslator, wPictNodeTranslator], hardBreak: wBrNodeTranslator, commentRangeStart: wCommentRangeStartTranslator, commentRangeEnd: wCommentRangeEndTranslator, permStart: wPermStartTranslator, permEnd: wPermEndTranslator, - commentReference: () => null, + commentReference: [], footnoteReference: wFootnoteReferenceTranslator, - shapeContainer: pictTranslator, - shapeTextbox: pictTranslator, - contentBlock: pictTranslator, + shapeContainer: wPictNodeTranslator, + shapeTextbox: wPictNodeTranslator, + contentBlock: wPictNodeTranslator, vectorShape: translateVectorShape, shapeGroup: translateShapeGroup, structuredContent: wSdtNodeTranslator, @@ -197,22 +197,31 @@ export function exportSchemaToJson(params) { passthroughInline: translatePassthroughNode, }; - let handler = router[type]; - - // For import/export v3 we use the translator directly - if (handler && 'decode' in handler && typeof handler.decode === 'function') { - return handler.decode(params); - } + const entry = router[type]; - if (!handler) { + if (!entry) { console.error('No translation function found for node type:', type); return null; } - // Call the handler for this node type - return handler(params); + + const handlers = Array.isArray(entry) ? entry : [entry]; + for (const handler of handlers) { + let result; + if (handler && 'decode' in handler && typeof handler.decode === 'function') { + result = handler.decode(params); + } else if (typeof handler === 'function') { + result = handler(params); + } + + if (result) { + return result; + } + } + + return null; } -function translatePassthroughNode(params) { +export function translatePassthroughNode(params: SCDecoderConfig) { const original = params?.node?.attrs?.originalXml; if (!original) return null; return carbonCopy(original); @@ -222,10 +231,9 @@ function translatePassthroughNode(params) { * There is no body node in the prose mirror schema, so it is stored separately * and needs to be restored here. * - * @param {ExportParams} params - * @returns {XmlReadyNode} JSON of the XML-ready body node + * @returns - JSON of the XML-ready body node */ -function translateBodyNode(params) { +function translateBodyNode(params: ExportParams): OpenXmlNode { let sectPr = params.bodyNode?.elements?.find((n) => n.name === 'w:sectPr'); if (!sectPr) { sectPr = { @@ -293,10 +301,10 @@ const generateDefaultHeaderFooter = (type, id) => { /** * Translate a heading node to a paragraph with Word heading style * - * @param {ExportParams} params The parameters object containing the heading node - * @returns {XmlReadyNode} JSON of the XML-ready paragraph node with heading style + * @param params - The parameters object containing the heading node + * @returns - JSON of the XML-ready paragraph node with heading style */ -function translateHeadingNode(params) { +function translateHeadingNode(params: ExportParams): OpenXmlNode | undefined { const { node } = params; const { level = 1, ...otherAttrs } = node.attrs; @@ -321,7 +329,7 @@ function translateHeadingNode(params) { * @param {string} originalIgnorable - The original mc:Ignorable string from import * @returns {string} Merged and deduplicated mc:Ignorable string */ -function mergeMcIgnorable(defaultIgnorable = '', originalIgnorable = '') { +function mergeMcIgnorable(defaultIgnorable: string = '', originalIgnorable: string = ''): string { const merged = [ ...new Set([...defaultIgnorable.split(/\s+/).filter(Boolean), ...originalIgnorable.split(/\s+/).filter(Boolean)]), ]; @@ -331,16 +339,16 @@ function mergeMcIgnorable(defaultIgnorable = '', originalIgnorable = '') { /** * Translate a document node * - * @param {ExportParams} params The parameters object - * @returns {XmlReadyNode} JSON of the XML-ready document node + * @param params The parameters object + * @returns - JSON of the XML-ready document node */ -function translateDocumentNode(params) { +function translateDocumentNode(params: ExportParams): [OpenXmlNode, ExportParams] { const bodyNode = { type: 'body', content: params.node.content, - }; + } as const; - const translatedBodyNode = exportSchemaToJson({ ...params, node: bodyNode }); + const translatedBodyNode: OpenXmlNode = exportSchemaToJson({ ...params, node: bodyNode }); // Merge original document attributes with defaults to preserve custom namespaces const originalAttrs = params.converter?.documentAttributes || {}; @@ -350,6 +358,7 @@ function translateDocumentNode(params) { }; // Merge mc:Ignorable lists - combine both default and original ignorable namespaces + // @ts-expect-error FIXME: originalAttrs['mc:Ignorable'] could be a number const mergedIgnorable = mergeMcIgnorable(DEFAULT_DOCX_DEFS['mc:Ignorable'], originalAttrs['mc:Ignorable']); if (mergedIgnorable) { attributes['mc:Ignorable'] = mergedIgnorable; @@ -367,10 +376,10 @@ function translateDocumentNode(params) { /** * Wrap a text node in a run * - * @param {XmlReadyNode} node - * @returns {XmlReadyNode} The wrapped run node + * @param {OpenXmlNode} node + * @returns {OpenXmlNode} The wrapped run node */ -export function wrapTextInRun(nodeOrNodes, marks) { +export function wrapTextInRun(nodeOrNodes, marks): OpenXmlNode { let elements = []; if (Array.isArray(nodeOrNodes)) elements = nodeOrNodes; else elements = [nodeOrNodes]; @@ -388,7 +397,7 @@ export function wrapTextInRun(nodeOrNodes, marks) { * @param {Object[]} marks The marks to add to the run properties * @returns */ -export function generateRunProps(marks = []) { +export function generateRunProps(marks: object[] = []) { return { name: 'w:rPr', elements: marks.filter((mark) => !!Object.keys(mark).length), @@ -397,11 +406,8 @@ export function generateRunProps(marks = []) { /** * Get all marks as a list of MarkType objects - * - * @param {MarkType[]} marks - * @returns */ -export function processOutputMarks(marks = []) { +export function processOutputMarks(marks: MarkType[] = []) { return marks.flatMap((mark) => { if (mark.type === 'textStyle') { return Object.entries(mark.attrs) @@ -420,15 +426,15 @@ export function processOutputMarks(marks = []) { * Translate a mark to an XML ready attribute * * @param {MarkType} mark - * @returns {Object} The XML ready mark attribute */ -function translateMark(mark) { +function translateMark(mark: MarkType) { const xmlMark = SuperConverter.markTypes.find((m) => m.type === mark.type); if (!xmlMark) { return {}; } - const markElement = { name: xmlMark.name, attributes: {} }; + // FIXME: properly type markElement + const markElement: Record = { name: xmlMark.name, attributes: {} }; const { attrs } = mark; let value; @@ -454,6 +460,7 @@ function translateMark(mark) { case 'underline': { const translated = wUnderlineTranslator.decode({ + // @ts-expect-error FIXME: missing "type" node: { attrs: { underlineType: attrs.underlineType ?? attrs.underline ?? null, @@ -527,6 +534,7 @@ function translateMark(mark) { break; case 'highlight': { const highlightValue = attrs.color ?? attrs.highlight ?? null; + // @ts-expect-error FIXME: missing "type" const translated = wHighlightTranslator.decode({ node: { attrs: { highlight: highlightValue } } }); return translated || {}; } @@ -542,7 +550,9 @@ function translateMark(mark) { } export class DocxExporter { - constructor(converter) { + converter: SuperConverter; + + constructor(converter: SuperConverter) { this.converter = converter; } @@ -557,6 +567,7 @@ export class DocxExporter { const xmlTag = ` ` ${key}="${value}"`) .join('')}?>`; + // @ts-expect-error FIXME: "debug" isn't used by #generateXml const result = this.#generateXml(json, debug); const final = [xmlTag, ...result]; return final; @@ -608,14 +619,20 @@ export class DocxExporter { * }; * // Returns: ['', 'Textcontent', ''] */ - #generateXml(node) { + #generateXml(node: { + name: string; + attributes?: object; + elements?: Array; + type?: string; + text?: string; + }): string[] | string | null { if (!node) return null; - let { name } = node; + const { name } = node; const { elements, attributes } = node; let tag = `<${name}`; - for (let attr in attributes) { + for (const attr in attributes) { const parsedAttrName = typeof attributes[attr] === 'string' ? this.#replaceSpecialCharacters(attributes[attr]) : attributes[attr]; tag += ` ${attr}="${parsedAttrName}"`; @@ -624,7 +641,7 @@ export class DocxExporter { const selfClosing = name && (!elements || !elements.length); if (selfClosing) tag += ' />'; else tag += '>'; - let tags = [tag]; + const tags = [tag]; if (!name && node.type === 'text') { return this.#replaceSpecialCharacters(node.text ?? ''); @@ -657,7 +674,7 @@ export class DocxExporter { } } else { if (elements) { - for (let child of elements) { + for (const child of elements) { const newElements = this.#generateXml(child); if (!newElements) { continue; diff --git a/packages/super-editor/src/core/super-converter/styles.js b/packages/super-editor/src/core/super-converter/styles.js index a2cc895636..d8fb5fee6e 100644 --- a/packages/super-editor/src/core/super-converter/styles.js +++ b/packages/super-editor/src/core/super-converter/styles.js @@ -25,7 +25,6 @@ export { resolveRunProperties, resolveParagraphProperties, combineRunProperties * @returns {(fontName: string, docx?: Record) => string} */ const getToCssFontFamily = () => { - // @ts-expect-error - SuperConverter.toCssFontFamily exists but isn't typed return SuperConverter.toCssFontFamily; }; @@ -633,7 +632,6 @@ function getFontFamilyValue(attributes, docx) { if (!resolved) return null; - // @ts-expect-error - toCssFontFamily is a static method on SuperConverter return SuperConverter.toCssFontFamily(resolved, docx); } diff --git a/packages/super-editor/src/core/super-converter/types.js b/packages/super-editor/src/core/super-converter/types.js deleted file mode 100644 index 40fafb331d..0000000000 --- a/packages/super-editor/src/core/super-converter/types.js +++ /dev/null @@ -1,15 +0,0 @@ -// @ts-check - -/** - * @typedef {import('../Editor.js').Editor} Editor - */ - -/** - * @typedef {import('./docx-helpers/docx-constants.js').RelationshipType} RelationshipType - */ - -/** - * @typedef {Object} XmlRelationshipElement - */ - -export {}; diff --git a/packages/super-editor/src/core/super-converter/types.ts b/packages/super-editor/src/core/super-converter/types.ts new file mode 100644 index 0000000000..02d883516a --- /dev/null +++ b/packages/super-editor/src/core/super-converter/types.ts @@ -0,0 +1,16 @@ +// @ts-check + +import type { translator as wAbstractNumTranslator } from '@converter/v3/handlers/w/abstractNum'; +import type { translator as wNumTranslator } from '@converter/v3/handlers/w/num'; + +export type { Editor } from '../Editor'; +export type { RelationshipType } from './docx-helpers/docx-constants'; + +export type XmlRelationshipElement = Record; + +export type Numbering = { + abstracts?: Record>; + definitions?: Record>; +}; + +export {}; diff --git a/packages/super-editor/src/core/super-converter/v2/exporter/helpers/translateChildNodes.js b/packages/super-editor/src/core/super-converter/v2/exporter/helpers/translateChildNodes.js index de6291d23f..dbb3a6f0d3 100644 --- a/packages/super-editor/src/core/super-converter/v2/exporter/helpers/translateChildNodes.js +++ b/packages/super-editor/src/core/super-converter/v2/exporter/helpers/translateChildNodes.js @@ -4,7 +4,7 @@ import { exportSchemaToJson } from '../../../exporter.js'; * Process child nodes, ignoring any that are not valid * * @param {import('@converter/exporter').SchemaNode[]} nodes The input nodes - * @returns {import('@converter/exporter').XmlReadyNode[]} The processed child nodes + * @returns {import('@converter/v2/types').OpenXmlNode[]} The processed child nodes */ export function translateChildNodes(params) { const { content: nodes } = params.node; diff --git a/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js index 243d5c3428..d06c7482eb 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js @@ -8,7 +8,7 @@ import { translator as wPNodeTranslator } from '../../v3/handlers/w/p/index.js'; * in order to combine list item nodes into list nodes. * * @param {import('../../v3/node-translator').SCEncoderConfig} params - * @returns {Object} Handler result + * @returns {import('@converter/v2/importer/types').NodeHandlerResult} Handler result */ export const handleParagraphNode = (params) => { const { nodes } = params; diff --git a/packages/super-editor/src/core/super-converter/v2/importer/pictNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/pictNodeImporter.js index 14e0b0f075..1377ab7efb 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/pictNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/pictNodeImporter.js @@ -1,78 +1,10 @@ // @ts-check -import { translator as pictTranslator } from '../../v3/handlers/w/pict/pict-translator'; -import { translator as w_pPrTranslator } from '../../v3/handlers/w/pPr'; -import { parseProperties } from './importerHelpers.js'; - -/** @type {Set} */ -const INLINE_PICT_RESULT_TYPES = new Set(['image', 'contentBlock']); +import { generateV2HandlerEntity } from '@converter/v3/handlers/utils.js'; +import { translator as pictTranslator } from '@converter/v3/handlers/w/pict/pict-translator'; /** - * Build paragraph attributes from a w:p node for wrapping inline pict results. - * @param {Object} pNode - The XML w:p node - * @param {Object} params - Import params containing docx context - * @returns {Object} Paragraph attributes including paragraphProperties, rsidRDefault, filename + * @type {import("@converter/v2/importer/docxImporter").NodeHandlerEntry} */ -const buildParagraphAttrsFromPNode = (pNode, params) => { - const { attributes = {} } = parseProperties(pNode); - const pPr = pNode?.elements?.find((el) => el.name === 'w:pPr'); - const inlineParagraphProperties = pPr ? w_pPrTranslator.encode({ ...params, nodes: [pPr] }) || {} : {}; - - return { - ...attributes, - paragraphProperties: inlineParagraphProperties, - rsidRDefault: pNode?.attributes?.['w:rsidRDefault'], - filename: params?.filename, - }; -}; - -export const handlePictNode = (params) => { - const { nodes } = params; - - if (!nodes.length || nodes[0].name !== 'w:p') { - return { nodes: [], consumed: 0 }; - } - - const pNode = nodes[0]; - const runs = pNode.elements?.filter((el) => el.name === 'w:r') || []; - - let pict = null; - for (const run of runs) { - const foundPict = run.elements?.find((el) => el.name === 'w:pict'); - if (foundPict) { - pict = foundPict; - break; - } - } - - // if there is no pict, then process as a paragraph or list. - if (!pict) { - return { nodes: [], consumed: 0 }; - } - - const node = pict; - const result = pictTranslator.encode({ ...params, extraParams: { node, pNode } }); - - if (!result) { - return { nodes: [], consumed: 0 }; - } - - const shouldWrapInParagraph = INLINE_PICT_RESULT_TYPES.has(result.type); - const wrappedNode = shouldWrapInParagraph - ? { - type: 'paragraph', - content: [result], - attrs: buildParagraphAttrsFromPNode(pNode, params), - marks: [], - } - : result; - - return { - nodes: [wrappedNode], - consumed: 1, - }; -}; +export const pictNodeHandlerEntity = generateV2HandlerEntity('handlePictNode', pictTranslator); -export const pictNodeHandlerEntity = { - handlerName: 'handlePictNode', - handler: handlePictNode, -}; +export const handlePictNode = pictNodeHandlerEntity.handler; diff --git a/packages/super-editor/src/core/super-converter/v2/importer/pictNodeImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/pictNodeImporter.test.js deleted file mode 100644 index 03451f8827..0000000000 --- a/packages/super-editor/src/core/super-converter/v2/importer/pictNodeImporter.test.js +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { handlePictNode } from './pictNodeImporter.js'; - -vi.mock('../../v3/handlers/w/pict/pict-translator', () => ({ - translator: { - encode: vi.fn(), - }, -})); - -vi.mock('../../v3/handlers/w/pPr', () => ({ - translator: { - encode: vi.fn(() => ({ jc: 'center' })), - }, -})); - -vi.mock('./importerHelpers.js', () => ({ - parseProperties: vi.fn(() => ({ attributes: { testAttr: 'value' } })), -})); - -import { translator as pictTranslator } from '../../v3/handlers/w/pict/pict-translator'; - -const createPNodeWithPict = (pictResult, pPrElements = [], rsidRDefault = '00AB1234') => ({ - name: 'w:p', - attributes: { 'w:rsidRDefault': rsidRDefault }, - elements: [ - ...(pPrElements.length ? [{ name: 'w:pPr', elements: pPrElements }] : []), - { - name: 'w:r', - elements: [{ name: 'w:pict', elements: [] }], - }, - ], -}); - -describe('handlePictNode', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns empty result when nodes array is empty', () => { - const result = handlePictNode({ nodes: [] }); - expect(result).toEqual({ nodes: [], consumed: 0 }); - }); - - it('returns empty result when first node is not w:p', () => { - const result = handlePictNode({ nodes: [{ name: 'w:r' }] }); - expect(result).toEqual({ nodes: [], consumed: 0 }); - }); - - it('returns empty result when paragraph has no w:pict element', () => { - const pNode = { - name: 'w:p', - elements: [{ name: 'w:r', elements: [{ name: 'w:t' }] }], - }; - const result = handlePictNode({ nodes: [pNode] }); - expect(result).toEqual({ nodes: [], consumed: 0 }); - }); - - it('returns empty result when pictTranslator returns null', () => { - pictTranslator.encode.mockReturnValue(null); - const pNode = createPNodeWithPict(null); - const result = handlePictNode({ nodes: [pNode] }); - expect(result).toEqual({ nodes: [], consumed: 0 }); - }); - - it('wraps image result in a paragraph node', () => { - const imageResult = { type: 'image', attrs: { src: 'test.png' } }; - pictTranslator.encode.mockReturnValue(imageResult); - - const pNode = createPNodeWithPict(imageResult); - const result = handlePictNode({ nodes: [pNode], filename: 'document.xml' }); - - expect(result.consumed).toBe(1); - expect(result.nodes).toHaveLength(1); - expect(result.nodes[0].type).toBe('paragraph'); - expect(result.nodes[0].content).toEqual([imageResult]); - expect(result.nodes[0].marks).toEqual([]); - expect(result.nodes[0].attrs.rsidRDefault).toBe('00AB1234'); - expect(result.nodes[0].attrs.filename).toBe('document.xml'); - }); - - it('wraps contentBlock result in a paragraph node', () => { - const contentBlockResult = { type: 'contentBlock', attrs: { id: '123' } }; - pictTranslator.encode.mockReturnValue(contentBlockResult); - - const pNode = createPNodeWithPict(contentBlockResult); - const result = handlePictNode({ nodes: [pNode] }); - - expect(result.nodes[0].type).toBe('paragraph'); - expect(result.nodes[0].content).toEqual([contentBlockResult]); - }); - - it('does not wrap non-inline results (e.g., passthroughBlock)', () => { - const passthroughResult = { type: 'passthroughBlock', attrs: { originalName: 'w:pict' } }; - pictTranslator.encode.mockReturnValue(passthroughResult); - - const pNode = createPNodeWithPict(passthroughResult); - const result = handlePictNode({ nodes: [pNode] }); - - expect(result.consumed).toBe(1); - expect(result.nodes).toHaveLength(1); - expect(result.nodes[0]).toEqual(passthroughResult); - }); - - it('includes paragraph properties from w:pPr in wrapped paragraph attrs', () => { - const imageResult = { type: 'image', attrs: { src: 'test.png' } }; - pictTranslator.encode.mockReturnValue(imageResult); - - const pNode = createPNodeWithPict(imageResult, [{ name: 'w:jc', attributes: { 'w:val': 'center' } }]); - const result = handlePictNode({ nodes: [pNode] }); - - expect(result.nodes[0].attrs.paragraphProperties).toEqual({ jc: 'center' }); - }); - - it('includes testAttr from parseProperties in wrapped paragraph attrs', () => { - const imageResult = { type: 'image', attrs: { src: 'test.png' } }; - pictTranslator.encode.mockReturnValue(imageResult); - - const pNode = createPNodeWithPict(imageResult); - const result = handlePictNode({ nodes: [pNode] }); - - expect(result.nodes[0].attrs.testAttr).toBe('value'); - }); -}); diff --git a/packages/super-editor/src/core/super-converter/v2/importer/types/index.js b/packages/super-editor/src/core/super-converter/v2/importer/types/index.js index d8163d7dc2..0aa80f16aa 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/types/index.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/types/index.js @@ -28,7 +28,8 @@ * @typedef {{handler: NodeListHandlerFn, handlerEntities: NodeHandlerEntry[]}} NodeListHandler * @typedef {(params: NodeHandlerParams) => PmNodeJson[]} NodeListHandlerFn * - * @typedef {(params: NodeHandlerParams) => {nodes: PmNodeJson[], consumed: number}} NodeHandler + * @typedef {{nodes: PmNodeJson[], consumed: number}} NodeHandlerResult + * @typedef {(params: NodeHandlerParams) => NodeHandlerResult} NodeHandler * @typedef {{handlerName: string, handler: NodeHandler}} NodeHandlerEntry * * @typedef {Object} SuperConverter diff --git a/packages/super-editor/src/core/super-converter/v2/types/index.js b/packages/super-editor/src/core/super-converter/v2/types/index.js index f9b4351a4b..fa7a1012bd 100644 --- a/packages/super-editor/src/core/super-converter/v2/types/index.js +++ b/packages/super-editor/src/core/super-converter/v2/types/index.js @@ -8,7 +8,7 @@ * @typedef {object} OpenXmlNode * @property {string} name * @property {string} [type] - * @property {object} [attributes] + * @property {Record} [attributes] * @property {OpenXmlNode[]} [elements] * @property {string} [text] */ diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js index ff0fc66bcc..79c669321b 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js @@ -33,7 +33,7 @@ const encode = (params) => { /** * Decode the page-number node back into OOXML structure. * @param {import('@translator').SCDecoderConfig} params - * @returns {import('@translator').SCDecoderResult[]} + * @returns {import('@translator').SCDecoderResult} */ const decode = (params) => { const { node } = params; @@ -106,6 +106,7 @@ const decode = (params) => { }, ]; + // @ts-expect-error FIXME: outputMarks doesn't conform to OpenXmlNode[] return translated; }; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/sd/indexEntry/indexEntry-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/sd/indexEntry/indexEntry-translator.js index 6166438f4c..b218d99f2d 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/sd/indexEntry/indexEntry-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/sd/indexEntry/indexEntry-translator.js @@ -37,12 +37,18 @@ const encode = (params) => { /** * Decode the indexEntry node back into OOXML field structure. * @param {import('@translator').SCDecoderConfig} params - * @returns {import('@translator').SCDecoderResult[]} + * @returns {import('@translator').SCDecoderResult} */ const decode = (params) => { const { node } = params; const outputMarks = processOutputMarks(node.attrs?.marksAsAttrs || []); - const contentNodes = (node.content ?? []).flatMap((n) => exportSchemaToJson({ ...params, node: n })); + // FIXME: exportSchemaToJson can alternatively return an array or a single value + const contentNodes = node.content.map( + (n) => + /** @type {import('@converter/v2/types').OpenXmlNode} */ ( + /** @type {unknown} */ (exportSchemaToJson({ ...params, node: n })) + ), + ); const instructionElements = buildInstructionElements(node.attrs?.instruction, node.attrs?.instructionTokens); return [ diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/sd/pageReference/pageReference-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/sd/pageReference/pageReference-translator.js index 40967bcac3..b8ccdeb04c 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/sd/pageReference/pageReference-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/sd/pageReference/pageReference-translator.js @@ -38,7 +38,7 @@ const encode = (params) => { /** * Decode the lineBreak / hardBreak node back into OOXML . * @param {import('@translator').SCDecoderConfig} params - * @returns {import('@translator').SCDecoderResult[]} + * @returns {import('@translator').SCDecoderResult} */ const decode = (params) => { const { node } = params; @@ -113,6 +113,7 @@ const decode = (params) => { }, ]; + // @ts-expect-error FIXME: missing "name" return translated; }; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/sd/shared/instruction-elements.js b/packages/super-editor/src/core/super-converter/v3/handlers/sd/shared/instruction-elements.js index b36bf4d9ae..f60848de83 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/sd/shared/instruction-elements.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/sd/shared/instruction-elements.js @@ -14,7 +14,7 @@ * * @param {string | null | undefined} instruction - The instruction text string * @param {InstructionToken[] | null | undefined} instructionTokens - Raw instruction tokens preserving tabs - * @returns {Array} Array of OOXML instruction elements + * @returns {Array} Array of OOXML instruction elements * * @example * // With tokens (preserves tabs) @@ -32,6 +32,7 @@ export const buildInstructionElements = (instruction, instructionTokens) => { const tokens = Array.isArray(instructionTokens) ? instructionTokens : []; if (tokens.length > 0) { + // @ts-expect-error FIXME: missing "name" return tokens.map((token) => { if (token?.type === 'tab') { return { name: 'w:tab', elements: [] }; @@ -49,6 +50,7 @@ export const buildInstructionElements = (instruction, instructionTokens) => { { name: 'w:instrText', attributes: { 'xml:space': 'preserve' }, + // @ts-expect-error FIXME: missing "name" elements: [{ type: 'text', text: instruction ?? '' }], }, ]; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/sd/tableOfContents/tableOfContents-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/sd/tableOfContents/tableOfContents-translator.js index 83fc9f6d84..99bb26d139 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/sd/tableOfContents/tableOfContents-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/sd/tableOfContents/tableOfContents-translator.js @@ -35,12 +35,18 @@ const encode = (params) => { /** * Decode the tableOfContents node back into OOXML . * @param {import('@translator').SCDecoderConfig} params - * @returns {import('@translator').SCDecoderResult[]} + * @returns {import('@translator').SCDecoderResult} */ const decode = (params) => { const { node } = params; - const contentNodes = node.content.map((n) => exportSchemaToJson({ ...params, node: n })); + // FIXME: exportSchemaToJson can alternatively return an array or a single value + const contentNodes = node.content.map( + (n) => + /** @type {import('@converter/v2/types').OpenXmlNode} */ ( + /** @type {unknown} */ (exportSchemaToJson({ ...params, node: n })) + ), + ); // Inject the fldChar begin, instrText and fldChar separate into the first child (after any existing pPr) const tocBeginElements = [ diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js index 9e22c67157..ae6b47b2eb 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js @@ -33,7 +33,7 @@ const encode = (params) => { /** * Decode the total-page-number node back into OOXML structure. * @param {import('@translator').SCDecoderConfig} params - * @returns {import('@translator').SCDecoderResult[]} + * @returns {import('@translator').SCDecoderResult} */ const decode = (params) => { const { node } = params; @@ -106,6 +106,7 @@ const decode = (params) => { }, ]; + // @ts-expect-error FIXME: missing "name" return translated; }; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/drawing/drawing-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/drawing/drawing-translator.js index ec5d2698ee..b960f74bb6 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/drawing/drawing-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/drawing/drawing-translator.js @@ -58,6 +58,10 @@ function decode(params) { return null; } + if (node.attrs.isPict) { + return null; + } + const childTranslator = node.attrs.isAnchor ? wpAnchorTranslator : wpInlineTranslator; const resultNode = childTranslator.decode(params); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js index c4284e4636..c66dab81f0 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js @@ -5,7 +5,7 @@ import { translator as wPPrNodeTranslator } from '../../pPr/pPr-translator.js'; * Generate the w:pPr props for a paragraph node * * @param {SchemaNode} node - * @returns {XmlReadyNode} The paragraph properties node + * @returns {import('@converter/v2/types').OpenXmlNode} The paragraph properties node */ export function generateParagraphProperties(params) { const { node } = params; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js index 7132c7b439..2a9b8067fa 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js @@ -81,7 +81,7 @@ function mergeConsecutiveTrackedChanges(elements) { * Translate a paragraph node * * @param {ExportParams} node A prose mirror paragraph node - * @returns {XmlReadyNode} JSON of the XML-ready paragraph node + * @returns {import('@converter/v2/types').OpenXmlNode} JSON of the XML-ready paragraph node */ export function translateParagraphNode(params) { let elements = translateChildNodes(params); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.js index 7b2f815d6f..d2fd0460be 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.js @@ -30,7 +30,7 @@ const encode = (params, encodedAttrs = {}) => { * Decode a SuperDoc paragraph node back into OOXML . * @param {import('@translator').SCDecoderConfig} params * @param {import('@translator').DecodedAttributes} [decodedAttrs] - * @returns {import('@translator').SCDecoderResult} + * @returns {import('@converter/v2/types').OpenXmlNode | undefined} */ const decode = (params, decodedAttrs = {}) => { const translated = translateParagraphNode(params); @@ -42,7 +42,6 @@ const decode = (params, decodedAttrs = {}) => { return translated; }; -/** @type {import('@translator').NodeTranslatorConfig} */ export const config = { xmlName: XML_NODE_NAME, sdNodeOrKeyName: SD_NODE_NAME, @@ -54,6 +53,5 @@ export const config = { /** * The NodeTranslator instance for the element. - * @type {import('@translator').NodeTranslator} */ export const translator = NodeTranslator.from(config); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-image-watermark-import.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-image-watermark-import.js index f7fc49d3ea..28442e4b74 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-image-watermark-import.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-image-watermark-import.js @@ -1,3 +1,5 @@ +import { carbonCopy } from '@core/utilities/carbonCopy.js'; + /** * Handles VML shape elements with v:imagedata (image watermarks). * @@ -75,10 +77,14 @@ export function handleShapeImageWatermarkImport({ params, pict }) { const blacklevel = imagedataAttrs['blacklevel']; const title = imagedataAttrs['o:title'] || 'Watermark'; + // Pass through any extra children of the pict element + const passthroughElements = pict.elements.filter((el) => el !== shape); + // Build the image node const imageNode = { type: 'image', attrs: { + isPict: true, src: normalizedPath, alt: title, extension: normalizedPath.substring(normalizedPath.lastIndexOf('.') + 1), @@ -117,6 +123,12 @@ export function handleShapeImageWatermarkImport({ params, pict }) { ...(gain && { gain }), ...(blacklevel && { blacklevel }), }, + content: passthroughElements.map((node) => ({ + type: 'passthroughInline', + attrs: { + originalXml: carbonCopy(node), + }, + })), }; return imageNode; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-image-watermark-import.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-image-watermark-import.test.js index aa099daf87..60071d27f4 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-image-watermark-import.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-image-watermark-import.test.js @@ -85,6 +85,7 @@ describe('handleShapeImageWatermarkImport', () => { gain: '19661f', blacklevel: '22938f', }), + content: [], }); }); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-text-watermark-import.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-text-watermark-import.js index 7eb8aa6617..043a61d743 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-text-watermark-import.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-text-watermark-import.js @@ -132,6 +132,7 @@ export function handleShapeTextWatermarkImport({ pict }) { const imageWatermarkNode = { type: 'image', attrs: { + isPict: true, src: svgDataUri, alt: watermarkText, title: watermarkText, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-textbox-import.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-textbox-import.ts similarity index 72% rename from packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-textbox-import.js rename to packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-textbox-import.ts index d1163298c5..9347501bf3 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-textbox-import.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-textbox-import.ts @@ -1,21 +1,24 @@ +import { SCEncoderConfig } from '@translator'; import { parseInlineStyles } from './parse-inline-styles'; -import { defaultNodeListHandler } from '@converter/v2/importer/docxImporter'; import { handleParagraphNode } from '@converter/v2/importer/paragraphNodeImporter'; import { collectTextBoxParagraphs, preProcessTextBoxContent, } from '@converter/v3/handlers/wp/helpers/textbox-content-helpers.js'; +import { OpenXmlNode } from '@converter/v2/types'; +import type { ShapeContainerAttrs, ShapeTextboxAttrs } from '@extensions/types/node-attributes'; +import type { NodeHandlerResult, PmNodeJson } from '@converter/v2/importer/types'; -/** - * @param {Object} options - * @returns {Object} - */ -export function handleShapeTextboxImport({ params, pict }) { +export function handleShapeTextboxImport({ params, pict }: { params: SCEncoderConfig; pict: OpenXmlNode }) { const shape = pict.elements?.find((el) => el.name === 'v:shape'); + if (!shape) { + console.error('Missing v:shape in v:pict'); + return null; + } - const schemaAttrs = {}; - const schemaTextboxAttrs = {}; - const shapeAttrs = shape.attributes || {}; + const schemaAttrs: ShapeContainerAttrs = {}; + const schemaTextboxAttrs: ShapeTextboxAttrs = {}; + const shapeAttrs: Record = shape.attributes || {}; schemaAttrs.attributes = shapeAttrs; @@ -45,14 +48,14 @@ export function handleShapeTextboxImport({ params, pict }) { const processedContent = preProcessTextBoxContent(textboxContent, params); const textboxParagraphs = collectTextBoxParagraphs(processedContent?.elements || []); - const content = textboxParagraphs.map((elem) => + const content: Array = textboxParagraphs.map((elem) => handleParagraphNode({ + ...params, nodes: [elem], docx: params.docx, - nodeListHandler: defaultNodeListHandler(), }), ); - const contentNodes = content.reduce((acc, current) => [...acc, ...current.nodes], []); + const contentNodes = content.reduce>((acc, current) => [...acc, ...current.nodes], []); const shapeTextbox = { type: 'shapeTextbox', diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-v-rect-import.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-v-rect-import.js index 7a60bc2578..1e8fa3b0b5 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-v-rect-import.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-v-rect-import.js @@ -1,12 +1,11 @@ import { parseInlineStyles } from './parse-inline-styles'; -import { translator as wPTranslator } from '@converter/v3/handlers/w/p'; /** * Handler for v:rect elements * @param {Object} options * @returns {Object} */ -export function handleVRectImport({ pNode, pict, params }) { +export function handleVRectImport({ pict }) { const rect = pict.elements?.find((el) => el.name === 'v:rect'); const schemaAttrs = {}; @@ -67,18 +66,12 @@ export function handleVRectImport({ pNode, pict, params }) { schemaAttrs.horizontalRule = true; } - const pElement = wPTranslator.encode({ - ...params, - nodes: [{ ...pNode, elements: pNode.elements.filter((el) => el.name !== 'w:r') }], - }); - pElement.content = [ + return [ { type: 'contentBlock', attrs: schemaAttrs, }, ]; - - return pElement; } export function parsePointsToPixels(value) { diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-v-rect-import.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-v-rect-import.test.js index e6392df74c..cae1686da1 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-v-rect-import.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/handle-v-rect-import.test.js @@ -2,13 +2,13 @@ import { describe, it, expect, vi } from 'vitest'; import { handleVRectImport } from './handle-v-rect-import'; import { parseInlineStyles } from './parse-inline-styles'; import { twipsToPixels, twipsToLines } from '@converter/helpers'; -import { defaultNodeListHandler } from '@converter/v2/importer/docxImporter.js'; vi.mock('./parse-inline-styles'); vi.mock('@converter/helpers'); describe('handleVRectImport', () => { const createPict = (rectAttributes = {}) => ({ + name: 'v:pict', elements: [ { name: 'v:rect', @@ -17,25 +17,6 @@ describe('handleVRectImport', () => { ], }); - const createPNode = (spacingAttrs = {}, indentAttrs = {}) => ({ - attributes: { 'w:rsidRDefault': '00000000' }, - elements: [ - { - name: 'w:pPr', - elements: [ - { - name: 'w:spacing', - attributes: spacingAttrs, - }, - { - name: 'w:ind', - attributes: indentAttrs, - }, - ], - }, - ], - }); - beforeEach(() => { vi.clearAllMocks(); parseInlineStyles.mockReturnValue({}); @@ -49,24 +30,15 @@ describe('handleVRectImport', () => { fillcolor: '#4472C4', }); - const options = { - params: { - docx: {}, - nodeListHandler: defaultNodeListHandler(), - }, - pNode: { elements: [] }, - pict, - }; - - const result = handleVRectImport(options); + const result = handleVRectImport({ pict }); + expect(result).toHaveLength(1); - expect(result.type).toBe('paragraph'); - expect(result.content[0].type).toBe('contentBlock'); - expect(result.content[0].attrs.attributes).toEqual({ + expect(result[0].type).toBe('contentBlock'); + expect(result[0].attrs.attributes).toEqual({ id: '_x0000_i1025', fillcolor: '#4472C4', }); - expect(result.content[0].attrs.background).toBe('#4472C4'); + expect(result[0].attrs.background).toBe('#4472C4'); }); it('should parse style and extract dimensions', () => { @@ -79,23 +51,15 @@ describe('handleVRectImport', () => { style: 'width:100pt;height:1.5pt', }); - const options = { - params: { - docx: {}, - nodeListHandler: defaultNodeListHandler(), - }, - pNode: { elements: [] }, - pict, - }; - - const result = handleVRectImport(options); + const result = handleVRectImport({ pict }); + expect(result).toHaveLength(1); expect(parseInlineStyles).toHaveBeenCalledWith('width:100pt;height:1.5pt'); - expect(result.content[0].attrs.size).toEqual({ + expect(result[0].attrs.size).toEqual({ width: 133, // 100 * 1.33 height: 2, // 1.5 * 1.33 rounded up }); - expect(result.content[0].attrs.style).toBe('width: 100pt;height: 1.5pt;'); + expect(result[0].attrs.style).toBe('width: 100pt;height: 1.5pt;'); }); it('should set width to 100% for full-page horizontal rules', () => { @@ -109,18 +73,10 @@ describe('handleVRectImport', () => { style: 'width:;height:1.5pt', }); - const options = { - params: { - docx: {}, - nodeListHandler: defaultNodeListHandler(), - }, - pNode: { elements: [] }, - pict, - }; - - const result = handleVRectImport(options); + const result = handleVRectImport({ pict }); + expect(result).toHaveLength(1); - expect(result.content[0].attrs.size.width).toBe('100%'); + expect(result[0].attrs.size.width).toBe('100%'); }); it('should extract VML attributes', () => { @@ -131,18 +87,10 @@ describe('handleVRectImport', () => { stroked: 'f', }); - const options = { - params: { - docx: {}, - nodeListHandler: defaultNodeListHandler(), - }, - pNode: { elements: [] }, - pict, - }; - - const result = handleVRectImport(options); + const result = handleVRectImport({ pict }); + expect(result).toHaveLength(1); - expect(result.content[0].attrs.vmlAttributes).toEqual({ + expect(result[0].attrs.vmlAttributes).toEqual({ hralign: 'center', hrstd: 't', hr: 't', @@ -155,79 +103,16 @@ describe('handleVRectImport', () => { const pict2 = createPict({ 'o:hrstd': 't' }); const result1 = handleVRectImport({ - params: { - docx: {}, - nodeListHandler: defaultNodeListHandler(), - }, - pNode: { elements: [] }, pict: pict1, }); const result2 = handleVRectImport({ - params: { - docx: {}, - nodeListHandler: defaultNodeListHandler(), - }, - pNode: { elements: [] }, pict: pict2, }); - expect(result1.content[0].attrs.horizontalRule).toBe(true); - expect(result2.content[0].attrs.horizontalRule).toBe(true); - }); + expect(result1).toHaveLength(1); + expect(result2).toHaveLength(1); - it('should parse spacing from pNode', () => { - const pNode = createPNode({ - 'w:after': '200', - 'w:before': '100', - 'w:line': '240', - 'w:lineRule': 'auto', - }); - - twipsToPixels.mockImplementation((val) => parseInt(val) / 20); - twipsToLines.mockImplementation((val) => parseInt(val) / 240); - - const options = { - params: { - docx: {}, - nodeListHandler: defaultNodeListHandler(), - }, - pNode, - pict: createPict({}), - }; - - const result = handleVRectImport(options); - - expect(result.attrs.paragraphProperties.spacing).toEqual({ - after: 200, - before: 100, - line: 240, - lineRule: 'auto', - }); - }); - - it('should parse indent from pNode', () => { - const pNode = createPNode( - {}, - { - 'w:left': '400', - 'w:right': '200', - }, - ); - - const options = { - params: { - docx: {}, - nodeListHandler: defaultNodeListHandler(), - }, - pNode, - pict: createPict({}), - }; - - const result = handleVRectImport(options); - - expect(result.attrs.paragraphProperties.indent).toEqual({ - left: 400, - right: 200, - }); + expect(result1[0].attrs.horizontalRule).toBe(true); + expect(result2[0].attrs.horizontalRule).toBe(true); }); }); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/pict-node-type-strategy.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/pict-node-type-strategy.test.js index 7b6d1a8c9e..011235ff6a 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/pict-node-type-strategy.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/pict-node-type-strategy.test.js @@ -3,32 +3,12 @@ import { pictNodeTypeStrategy } from './pict-node-type-strategy'; import { handleVRectImport } from './handle-v-rect-import'; import { handleShapeTextboxImport } from './handle-shape-textbox-import'; import { handleShapeImageWatermarkImport } from './handle-shape-image-watermark-import'; +import { createPict, createRect, createShape, createGroup, createTextbox } from '@tests/helpers/pict-helpers'; describe('pictNodeTypeStrategy', () => { - const createNode = (elements = []) => ({ - elements, - }); - - const createRect = () => ({ - name: 'v:rect', - }); - - const createShape = (elements = []) => ({ - name: 'v:shape', - elements, - }); - - const createGroup = () => ({ - name: 'v:group', - }); - - const createTextbox = () => ({ - name: 'v:textbox', - }); - describe('rect handler', () => { it('should return contentBlock type when rect element exists', () => { - const node = createNode([createRect()]); + const node = createPict([createRect()]); const result = pictNodeTypeStrategy(node); @@ -39,7 +19,7 @@ describe('pictNodeTypeStrategy', () => { }); it('should prioritize rect over shape', () => { - const node = createNode([createRect(), createShape([createTextbox()])]); + const node = createPict([createRect(), createShape([createTextbox()])]); const result = pictNodeTypeStrategy(node); @@ -50,7 +30,7 @@ describe('pictNodeTypeStrategy', () => { }); it('should prioritize rect over group', () => { - const node = createNode([createRect(), createGroup()]); + const node = createPict([createRect(), createGroup()]); const result = pictNodeTypeStrategy(node); @@ -63,7 +43,7 @@ describe('pictNodeTypeStrategy', () => { describe('shapeContainer handler', () => { it('should return shapeContainer type when shape contains textbox', () => { - const node = createNode([createShape([createTextbox()])]); + const node = createPict([createShape([createTextbox()])]); const result = pictNodeTypeStrategy(node); @@ -74,7 +54,7 @@ describe('pictNodeTypeStrategy', () => { }); it('should return unknown when shape exists but has no textbox', () => { - const node = createNode([createShape([])]); + const node = createPict([createShape([])]); const result = pictNodeTypeStrategy(node); @@ -85,7 +65,7 @@ describe('pictNodeTypeStrategy', () => { }); it('should return image type when shape contains imagedata (watermarks)', () => { - const node = createNode([createShape([{ name: 'v:imagedata' }, { name: 'v:fill' }])]); + const node = createPict([createShape([{ name: 'v:imagedata' }, { name: 'v:fill' }])]); const result = pictNodeTypeStrategy(node); @@ -98,7 +78,7 @@ describe('pictNodeTypeStrategy', () => { describe('image handler', () => { it('should return image type when shape contains imagedata', () => { - const node = createNode([createShape([{ name: 'v:imagedata', attributes: { 'r:id': 'rId1' } }])]); + const node = createPict([createShape([{ name: 'v:imagedata', attributes: { 'r:id': 'rId1' } }])]); const result = pictNodeTypeStrategy(node); @@ -109,7 +89,7 @@ describe('pictNodeTypeStrategy', () => { }); it('should prioritize textbox over imagedata when both present', () => { - const node = createNode([createShape([createTextbox(), { name: 'v:imagedata' }])]); + const node = createPict([createShape([createTextbox(), { name: 'v:imagedata' }])]); const result = pictNodeTypeStrategy(node); @@ -138,7 +118,7 @@ describe('pictNodeTypeStrategy', () => { }, ], }; - const node = createNode([shape]); + const node = createPict([shape]); const result = pictNodeTypeStrategy(node); @@ -151,7 +131,7 @@ describe('pictNodeTypeStrategy', () => { describe('group handler', () => { it('should return unknown when only group exists', () => { - const node = createNode([createGroup()]); + const node = createPict([createGroup()]); const result = pictNodeTypeStrategy(node); @@ -164,7 +144,7 @@ describe('pictNodeTypeStrategy', () => { describe('unknown handler', () => { it('should return unknown when no elements exist', () => { - const node = createNode([]); + const node = createPict([]); const result = pictNodeTypeStrategy(node); @@ -186,7 +166,7 @@ describe('pictNodeTypeStrategy', () => { }); it('should return unknown when only irrelevant elements exist', () => { - const node = createNode([{ name: 'v:imagedata' }, { name: 'v:fill' }]); + const node = createPict([{ name: 'v:imagedata' }, { name: 'v:fill' }]); const result = pictNodeTypeStrategy(node); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-image-watermark.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-image-watermark.js index 00693b3f10..c3d88e2d29 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-image-watermark.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-image-watermark.js @@ -1,11 +1,10 @@ import { generateRandomSigned32BitIntStrId } from '@helpers/generateDocxRandomId'; -import { wrapTextInRun } from '@converter/exporter'; /** * Translates an image node with VML watermark attributes back to w:pict XML. * * @param {Object} params - The parameters for translation. - * @returns {Object} The XML representation (w:p containing w:pict). + * @returns {Object} The XML representation (w:pict). */ export function translateImageWatermark(params) { const { node } = params; @@ -35,12 +34,7 @@ export function translateImageWatermark(params) { elements: [shape], }; - const par = { - name: 'w:p', - elements: [wrapTextInRun(pict)], - }; - - return par; + return pict; } // Fallback: construct VML from image attributes @@ -77,12 +71,7 @@ export function translateImageWatermark(params) { elements: [shape], }; - const par = { - name: 'w:p', - elements: [wrapTextInRun(pict)], - }; - - return par; + return pict; } /** diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-shape-container.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-shape-container.js index 14e6f995d8..68cd7d5920 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-shape-container.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-shape-container.js @@ -1,6 +1,5 @@ import { translateChildNodes } from '@converter/v2/exporter/helpers/translateChildNodes'; import { generateRandomSigned32BitIntStrId } from '@helpers/generateDocxRandomId'; -import { wrapTextInRun } from '@converter/exporter'; /** * @param {Object} params - The parameters for translation. @@ -37,10 +36,5 @@ export function translateShapeContainer(params) { elements: [shape], }; - const par = { - name: 'w:p', - elements: [wrapTextInRun(pict)], - }; - - return par; + return pict; } diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-shape-container.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-shape-container.test.js index cae64a4a11..83cf49d107 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-shape-container.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-shape-container.test.js @@ -35,30 +35,20 @@ describe('translateShapeContainer', () => { const result = translateShapeContainer(params); expect(result).toEqual({ - name: 'w:p', + name: 'w:pict', + attributes: { + 'w14:anchorId': '12345678', + }, elements: [ { - name: 'w:r', - elements: [ - { - name: 'w:pict', - attributes: { - 'w14:anchorId': '12345678', - }, - elements: [ - { - name: 'v:shape', - attributes: { - id: '_x0000_s1026', - type: '#_x0000_t202', - style: 'position:absolute', - fillcolor: '#4472C4', - }, - elements: mockElements, - }, - ], - }, - ], + name: 'v:shape', + attributes: { + id: '_x0000_s1026', + type: '#_x0000_t202', + style: 'position:absolute', + fillcolor: '#4472C4', + }, + elements: mockElements, }, ], }); @@ -81,7 +71,9 @@ describe('translateShapeContainer', () => { }; const result = translateShapeContainer(params); - const shape = result.elements[0].elements[0].elements[0]; + expect(result.name).toBe('w:pict'); + const shape = result.elements[0]; + expect(shape.name).toBe('v:shape'); expect(shape.elements).toContainEqual({ name: 'w10:wrap', @@ -105,7 +97,9 @@ describe('translateShapeContainer', () => { }; const result = translateShapeContainer(params); - const shape = result.elements[0].elements[0].elements[0]; + expect(result.name).toBe('w:pict'); + const shape = result.elements[0]; + expect(shape.name).toBe('v:shape'); expect(shape.elements).not.toContainEqual(expect.objectContaining({ name: 'w10:wrap' })); }); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-text-watermark.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-text-watermark.js index 98d6ac16de..98adc105b1 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-text-watermark.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-text-watermark.js @@ -1,11 +1,10 @@ import { generateRandomSigned32BitIntStrId } from '@helpers/generateDocxRandomId'; -import { wrapTextInRun } from '@converter/exporter'; /** * Translates a text watermark node back to w:pict XML with VML text path. * * @param {Object} params - The parameters for translation. - * @returns {Object} The XML representation (w:p containing w:pict). + * @returns {Object} The XML representation (w:pict). */ export function translateTextWatermark(params) { const { node } = params; @@ -70,12 +69,7 @@ export function translateTextWatermark(params) { elements: [shape], }; - const par = { - name: 'w:p', - elements: [wrapTextInRun(pict)], - }; - - return par; + return pict; } // Fallback: construct VML from text watermark attributes @@ -168,12 +162,7 @@ export function translateTextWatermark(params) { elements: [shape], }; - const par = { - name: 'w:p', - elements: [wrapTextInRun(pict)], - }; - - return par; + return pict; } /** diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-text-watermark.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-text-watermark.test.js index 49e5c2e83c..b9b4ee2563 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-text-watermark.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/helpers/translate-text-watermark.test.js @@ -61,12 +61,8 @@ describe('translateTextWatermark', () => { const result = translateTextWatermark({ node }); - expect(result.name).toBe('w:p'); - expect(result.elements).toHaveLength(1); - expect(result.elements[0].name).toBe('w:r'); - - const pict = result.elements[0].elements[0]; - expect(pict.name).toBe('w:pict'); + expect(result.name).toBe('w:pict'); + const pict = result; const shape = pict.elements[0]; expect(shape.name).toBe('v:shape'); @@ -114,8 +110,10 @@ describe('translateTextWatermark', () => { }; const result = translateTextWatermark({ node }); + expect(result.name).toBe('w:pict'); + const shape = result.elements[0]; + expect(shape.name).toBe('v:shape'); - const shape = result.elements[0].elements[0].elements[0]; const textpath = shape.elements.find((el) => el.name === 'v:textpath'); expect(textpath.attributes.string).toBe('NEW TEXT'); }); @@ -143,8 +141,10 @@ describe('translateTextWatermark', () => { }; const result = translateTextWatermark({ node }); + expect(result.name).toBe('w:pict'); + const shape = result.elements[0]; + expect(shape.name).toBe('v:shape'); - const shape = result.elements[0].elements[0].elements[0]; const fill = shape.elements.find((el) => el.name === 'v:fill'); const stroke = shape.elements.find((el) => el.name === 'v:stroke'); @@ -203,9 +203,9 @@ describe('translateTextWatermark', () => { const result = translateTextWatermark({ node }); - expect(result.name).toBe('w:p'); + expect(result.name).toBe('w:pict'); - const shape = result.elements[0].elements[0].elements[0]; + const shape = result.elements[0]; expect(shape.name).toBe('v:shape'); expect(shape.attributes.id).toContain('PowerPlusWaterMarkObject'); expect(shape.attributes.type).toBe('#_x0000_t136'); @@ -240,8 +240,10 @@ describe('translateTextWatermark', () => { }; const result = translateTextWatermark({ node }); + expect(result.name).toBe('w:pict'); + const shape = result.elements[0]; + expect(shape.name).toBe('v:shape'); - const shape = result.elements[0].elements[0].elements[0]; const path = shape.elements.find((el) => el.name === 'v:path'); expect(path).toBeDefined(); expect(path.attributes.textpathok).toBe('t'); @@ -266,8 +268,10 @@ describe('translateTextWatermark', () => { }; const result = translateTextWatermark({ node }); + expect(result.name).toBe('w:pict'); + const shape = result.elements[0]; + expect(shape.name).toBe('v:shape'); - const shape = result.elements[0].elements[0].elements[0]; const textpath = shape.elements.find((el) => el.name === 'v:textpath'); expect(textpath).toBeDefined(); expect(textpath.attributes.on).toBe('t'); @@ -296,8 +300,10 @@ describe('translateTextWatermark', () => { }; const result = translateTextWatermark({ node }); + expect(result.name).toBe('w:pict'); + const shape = result.elements[0]; + expect(shape.name).toBe('v:shape'); - const shape = result.elements[0].elements[0].elements[0]; const fill = shape.elements.find((el) => el.name === 'v:fill'); expect(fill).toBeDefined(); expect(fill.attributes.type).toBe('solid'); @@ -318,8 +324,10 @@ describe('translateTextWatermark', () => { }; const result = translateTextWatermark({ node }); + expect(result.name).toBe('w:pict'); + const shape = result.elements[0]; + expect(shape.name).toBe('v:shape'); - const shape = result.elements[0].elements[0].elements[0]; const fill = shape.elements.find((el) => el.name === 'v:fill'); expect(fill).toBeUndefined(); }); @@ -342,8 +350,10 @@ describe('translateTextWatermark', () => { }; const result = translateTextWatermark({ node }); + expect(result.name).toBe('w:pict'); + const shape = result.elements[0]; + expect(shape.name).toBe('v:shape'); - const shape = result.elements[0].elements[0].elements[0]; const stroke = shape.elements.find((el) => el.name === 'v:stroke'); expect(stroke).toBeDefined(); expect(stroke.attributes.color).toBe('#00ff00'); @@ -366,8 +376,10 @@ describe('translateTextWatermark', () => { }; const result = translateTextWatermark({ node }); + expect(result.name).toBe('w:pict'); + const shape = result.elements[0]; + expect(shape.name).toBe('v:shape'); - const shape = result.elements[0].elements[0].elements[0]; const stroke = shape.elements.find((el) => el.name === 'v:stroke'); expect(stroke).toBeUndefined(); }); @@ -387,8 +399,10 @@ describe('translateTextWatermark', () => { }; const result = translateTextWatermark({ node }); + expect(result.name).toBe('w:pict'); + const shape = result.elements[0]; + expect(shape.name).toBe('v:shape'); - const shape = result.elements[0].elements[0].elements[0]; const wrap = shape.elements.find((el) => el.name === 'w10:wrap'); expect(wrap).toBeDefined(); expect(wrap.attributes.type).toBe('square'); @@ -406,8 +420,10 @@ describe('translateTextWatermark', () => { }; const result = translateTextWatermark({ node }); + expect(result.name).toBe('w:pict'); + const shape = result.elements[0]; + expect(shape.name).toBe('v:shape'); - const shape = result.elements[0].elements[0].elements[0]; const wrap = shape.elements.find((el) => el.name === 'w10:wrap'); expect(wrap).toBeDefined(); expect(wrap.attributes.type).toBe('none'); @@ -428,8 +444,10 @@ describe('translateTextWatermark', () => { }; const result = translateTextWatermark({ node }); + expect(result.name).toBe('w:pict'); + const shape = result.elements[0]; + expect(shape.name).toBe('v:shape'); - const shape = result.elements[0].elements[0].elements[0]; expect(shape.attributes.stroked).toBe('f'); }); @@ -445,8 +463,10 @@ describe('translateTextWatermark', () => { }; const result = translateTextWatermark({ node }); + expect(result.name).toBe('w:pict'); + const shape = result.elements[0]; + expect(shape.name).toBe('v:shape'); - const shape = result.elements[0].elements[0].elements[0]; expect(shape.attributes.fillcolor).toBe('silver'); }); @@ -465,8 +485,10 @@ describe('translateTextWatermark', () => { }; const result = translateTextWatermark({ node }); + expect(result.name).toBe('w:pict'); + const shape = result.elements[0]; + expect(shape.name).toBe('v:shape'); - const shape = result.elements[0].elements[0].elements[0]; expect(shape.attributes.adj).toBe('10800'); }); }); @@ -505,8 +527,10 @@ describe('translateTextWatermark', () => { }; const result = translateTextWatermark({ node }); + expect(result.name).toBe('w:pict'); + const shape = result.elements[0]; + expect(shape.name).toBe('v:shape'); - const shape = result.elements[0].elements[0].elements[0]; const style = shape.attributes.style; expect(style).toContain('position:absolute'); @@ -535,8 +559,10 @@ describe('translateTextWatermark', () => { }; const result = translateTextWatermark({ node }); + expect(result.name).toBe('w:pict'); + const shape = result.elements[0]; + expect(shape.name).toBe('v:shape'); - const shape = result.elements[0].elements[0].elements[0]; const style = shape.attributes.style; expect(style).toContain('margin-left:0.05pt'); @@ -556,8 +582,10 @@ describe('translateTextWatermark', () => { }; const result = translateTextWatermark({ node }); + expect(result.name).toBe('w:pict'); + const shape = result.elements[0]; + expect(shape.name).toBe('v:shape'); - const shape = result.elements[0].elements[0].elements[0]; const style = shape.attributes.style; expect(style).not.toContain('rotation'); @@ -581,8 +609,10 @@ describe('translateTextWatermark', () => { }; const result = translateTextWatermark({ node }); + expect(result.name).toBe('w:pict'); + const shape = result.elements[0]; + expect(shape.name).toBe('v:shape'); - const shape = result.elements[0].elements[0].elements[0]; const textpath = shape.elements.find((el) => el.name === 'v:textpath'); const style = textpath.attributes.style; @@ -602,8 +632,10 @@ describe('translateTextWatermark', () => { }; const result = translateTextWatermark({ node }); + expect(result.name).toBe('w:pict'); + const shape = result.elements[0]; + expect(shape.name).toBe('v:shape'); - const shape = result.elements[0].elements[0].elements[0]; const textpath = shape.elements.find((el) => el.name === 'v:textpath'); expect(textpath.attributes.style).toBe(''); @@ -627,8 +659,10 @@ describe('translateTextWatermark', () => { }; const result = translateTextWatermark({ node }); + expect(result.name).toBe('w:pict'); + const shape = result.elements[0]; + expect(shape.name).toBe('v:shape'); - const shape = result.elements[0].elements[0].elements[0]; const style = shape.attributes.style; expect(style).toContain('width:72pt'); @@ -656,8 +690,10 @@ describe('translateTextWatermark', () => { }; const result = translateTextWatermark({ node }); + expect(result.name).toBe('w:pict'); + const shape = result.elements[0]; + expect(shape.name).toBe('v:shape'); - const shape = result.elements[0].elements[0].elements[0]; const elementNames = shape.elements.map((el) => el.name); expect(elementNames[0]).toBe('v:path'); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/pict-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/pict-translator.js index e1667bfd58..86080a34f2 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/pict-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/pict-translator.js @@ -5,6 +5,7 @@ import { translateShapeTextbox } from './helpers/translate-shape-textbox'; import { translateContentBlock } from './helpers/translate-content-block'; import { translateImageWatermark } from './helpers/translate-image-watermark'; import { translateTextWatermark } from './helpers/translate-text-watermark'; +import { translatePassthroughNode } from '@converter/exporter'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'w:pict'; @@ -19,8 +20,12 @@ const validXmlAttributes = []; // No attrs for "w:pict". * @param {import('@translator').SCEncoderConfig} params * @returns {import('@translator').SCEncoderResult} */ -function encode(params) { - const { node, pNode } = params.extraParams; +function encode({ nodes, ...params }) { + const [node] = nodes; + + if (!node) { + return undefined; + } const { type: pictType, handler } = pictNodeTypeStrategy(node); @@ -30,7 +35,6 @@ function encode(params) { const result = handler({ params, - pNode, pict: node, }); @@ -69,6 +73,13 @@ function decode(params) { const decoder = types[node.type] ?? types.default; const result = decoder(); + if (result) { + const passthroughNodes = node.content?.filter((n) => n.type === 'passthroughInline'); + if (passthroughNodes && passthroughNodes.length > 0) { + result.elements ??= []; + result.elements.push(...passthroughNodes.map((n) => translatePassthroughNode({ ...params, node: n }))); + } + } return result; } diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/pict-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/pict-translator.test.js new file mode 100644 index 0000000000..4439fbf3f4 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/pict-translator.test.js @@ -0,0 +1,87 @@ +import { beforeEach, it, expect } from 'vitest'; +import { translator } from './pict-translator.js'; + +import { createPict, createRect, createShape } from '@tests/helpers/pict-helpers'; +import { createRels } from '@tests/helpers/rels-helpers'; + +describe('w:pict translator', () => { + let mockImageId; + let mockDocx; + + describe('encode', () => { + beforeEach(() => { + mockImageId = 'rId12345'; + mockDocx = { + ...createRels({ rels: { [mockImageId]: 'media/image1.png' } }), + }; + }); + + it('returns empty result when nodes array is empty', () => { + const result = translator.encode({ nodes: [] }); + expect(result).toBeUndefined(); + }); + + it('returns empty result when first node is not w:pict', () => { + const result = translator.encode({ nodes: [{ name: 'w:p' }] }); + expect(result).toBeUndefined(); + }); + + it('returns empty result when w:pict is empty', () => { + const pictNode = { + name: 'w:pict', + elements: [], + }; + const result = translator.encode({ nodes: [pictNode] }); + expect(result).toBeUndefined(); + }); + + describe('with v:shape child', () => { + describe('with v:imagedata', () => { + it('returns an image', () => { + const pictNode = createPict([ + createShape([{ name: 'v:imagedata', attributes: { 'r:id': mockImageId } }, { name: 'v:fill' }]), + ]); + const result = translator.encode({ nodes: [pictNode], docx: mockDocx }); + + expect(result).toBeDefined(); + expect(result.type).toBe('image'); + // TODO: more assertions + }); + }); + + describe('with v:textpath', () => { + it('returns an image', () => { + const pictNode = createPict([createShape([{ name: 'v:textpath', attributes: { string: 'Hello world' } }])]); + const result = translator.encode({ nodes: [pictNode] }); + + expect(result).toBeDefined(); + expect(result.type).toBe('image'); + // TODO: more assertions + }); + }); + + describe('with v:textbox', () => { + it('returns a shapeContainer', () => { + const pictNode = createPict([createShape([{ name: 'v:textbox', attributes: {} }])]); + const result = translator.encode({ nodes: [pictNode] }); + + expect(result).toBeDefined(); + expect(result.type).toBe('shapeContainer'); + // TODO: more assertions + }); + }); + }); + + describe('with v:rect child', () => { + it('returns a contentBlock', () => { + const pictNode = createPict([createRect()]); + const result = translator.encode({ nodes: [pictNode] }); + + expect(result).toBeDefined(); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('contentBlock'); + // TODO: more assertions + }); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/pict-watermark.integration.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/pict-watermark.integration.test.js index b65e3aebf8..88cacd6ed6 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/pict-watermark.integration.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/pict/pict-watermark.integration.test.js @@ -99,18 +99,16 @@ describe('VML Watermark Integration Tests', () => { vmlAttributes: expect.any(Object), vmlImagedata: expect.any(Object), }), + content: [], }); // Step 2: Export - Convert from SuperDoc node back to DOCX XML const exportedXml = translateImageWatermark({ node: importedNode }); // Verify exported structure - expect(exportedXml.name).toBe('w:p'); - expect(exportedXml.elements).toHaveLength(1); - expect(exportedXml.elements[0].name).toBe('w:r'); + expect(exportedXml.name).toBe('w:pict'); - const pict = exportedXml.elements[0].elements.find((el) => el.name === 'w:pict'); - expect(pict).toBeDefined(); + const pict = exportedXml; expect(pict.attributes['w14:anchorId']).toBeDefined(); const shape = pict.elements.find((el) => el.name === 'v:shape'); @@ -171,9 +169,8 @@ describe('VML Watermark Integration Tests', () => { const exportedXml = translateImageWatermark({ node: programmaticNode }); // Verify the structure is created correctly - expect(exportedXml.name).toBe('w:p'); - const pict = exportedXml.elements[0].elements.find((el) => el.name === 'w:pict'); - expect(pict).toBeDefined(); + expect(exportedXml.name).toBe('w:pict'); + const pict = exportedXml; const shape = pict.elements.find((el) => el.name === 'v:shape'); expect(shape).toBeDefined(); @@ -371,12 +368,8 @@ describe('VML Watermark Integration Tests', () => { const exportedXml = translateTextWatermark({ node: importedNode }); // Verify exported structure - expect(exportedXml.name).toBe('w:p'); - expect(exportedXml.elements).toHaveLength(1); - expect(exportedXml.elements[0].name).toBe('w:r'); - - const pict = exportedXml.elements[0].elements[0]; - expect(pict.name).toBe('w:pict'); + expect(exportedXml.name).toBe('w:pict'); + const pict = exportedXml; const shape = pict.elements.find((el) => el.name === 'v:shape'); expect(shape).toBeDefined(); @@ -452,9 +445,8 @@ describe('VML Watermark Integration Tests', () => { const exportedXml = translateTextWatermark({ node: programmaticNode }); // Verify the structure is created correctly - expect(exportedXml.name).toBe('w:p'); - const pict = exportedXml.elements[0].elements[0]; - expect(pict).toBeDefined(); + expect(exportedXml.name).toBe('w:pict'); + const pict = exportedXml; const shape = pict.elements.find((el) => el.name === 'v:shape'); expect(shape).toBeDefined(); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.js index fec8092687..5af5478174 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.js @@ -4,7 +4,7 @@ * * @param {String} text Text node's content * @param {Object[]} marks The marks to add to the run properties - * @returns {XmlReadyNode} The translated text node + * @returns {import('@converter/v2/types').OpenXmlNode} The translated text node */ import { translator as wRPrNodeTranslator } from '../../rpr/rpr-translator.js'; import { combineRunProperties, decodeRPrFromMarks } from '@converter/styles.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/t/t-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/t/t-translator.js index d45c4b0c8f..c7a19a5d9a 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/t/t-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/t/t-translator.js @@ -121,6 +121,7 @@ const decode = (params) => { } const { text, marks = [] } = node; + // @ts-expect-error FIXME: missing "name" return getTextNodeForExport(text, marks, params); }; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.d.ts b/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.d.ts index 5b06a88f29..9c30fd4dae 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.d.ts +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.d.ts @@ -1,3 +1,5 @@ +import type { NodeTranslator, SCEncoderConfig } from '@translator'; + /** * Table styles result */ @@ -11,9 +13,12 @@ export interface TableStyles { /** * Table translator function */ -export function translator(node: unknown, params: unknown): unknown; +export const translator: NodeTranslator; /** * Gets referenced table styles from a style reference */ -export function _getReferencedTableStyles(tableStyleReference: string | null, params: unknown): TableStyles | null; +export function _getReferencedTableStyles( + tableStyleReference: string | null, + params: SCEncoderConfig, +): TableStyles | null; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js index baf6d52d50..b225506cdb 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js @@ -295,6 +295,7 @@ const decode = (params, decodedAttrs) => { firstRow, }, }); + // @ts-expect-error FIXME: type of element is incorrect if (element) elements.unshift(element); // Table properties @@ -304,6 +305,7 @@ const decode = (params, decodedAttrs) => { ...params, node: { ...node, attrs: { ...node.attrs, tableProperties: properties } }, }); + // @ts-expect-error FIXME: type of element is incorrect if (element) elements.unshift(element); } diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblGrid/tblGrid-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblGrid/tblGrid-translator.js index ecf0a4dbb1..386301e34a 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblGrid/tblGrid-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblGrid/tblGrid-translator.js @@ -86,6 +86,7 @@ const decode = (params) => { const minimumWidth = shouldEnforceMinimum ? cellMinWidth : 1; const safeWidth = Math.max(roundedWidth, minimumWidth); + // @ts-expect-error FIXME: missing required props for decode() const decoded = gridColTranslator.decode({ node: { type: /** @type {string} */ (gridColTranslator.sdNodeOrKeyName), attrs: { col: safeWidth } }, }); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.js index 8f411ceb5e..bd67ad63e2 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.js @@ -11,7 +11,7 @@ import { translator as tcPrTranslator } from '../../tcPr'; /** * Main translation function for a table cell. * @param {import('@converter/exporter').ExportParams} params - * @returns {import('@converter/exporter').XmlReadyNode} + * @returns {import('@converter/v2/types').OpenXmlNode} */ export function translateTableCell(params) { const elements = translateChildNodes({ @@ -31,7 +31,7 @@ export function translateTableCell(params) { /** * Generate w:tcPr properties node for a table cell * @param {import('@converter/exporter').SchemaNode} node - * @returns {import('@converter/exporter').XmlReadyNode} + * @returns {import('@converter/v2/types').OpenXmlNode} */ export function generateTableCellProperties(node) { const tableCellProperties = { ...(node.attrs?.tableCellProperties || {}) }; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tr/tr-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tr/tr-translator.js index 61077a44c9..b8bc81acb9 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tr/tr-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tr/tr-translator.js @@ -288,6 +288,7 @@ const decode = (params, decodedAttrs) => { ...params, node: { ...node, attrs: { ...node.attrs, tableRowProperties } }, }); + // @ts-expect-error FIXME: type of trPr is incorrect if (trPr) elements.unshift(trPr); } diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/u/u-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/u/u-translator.js index 28fd0412ab..c80fed94e4 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/u/u-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/u/u-translator.js @@ -71,6 +71,7 @@ export const config = { sdNodeOrKeyName: SD_ATTR_KEY, type: NodeTranslator.translatorTypes.ATTRIBUTE, encode, + // @ts-expect-error FIXME: type of decode is incorrect decode, attributes: validXmlAttributes, }; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index ba127ed394..1a64d3e341 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -4,15 +4,6 @@ import { prepareTextAnnotation } from '@converter/v3/handlers/w/sdt/helpers/tran import { wrapTextInRun } from '@converter/exporter.js'; import { generateDocxRandomId } from '@core/helpers/index.js'; -/** - * Decodes image into export XML - * @typedef {Object} ExportParams - * @property {Object} node JSON node to translate (from PM schema) - * @property {Object} bodyNode The stored body node to restore, if available - * @property {Object[]} relationships The relationships to add to the document - * @returns {Object} The XML representation. - */ - export const translateImageNode = (params) => { const { node: { attrs = {} }, @@ -305,7 +296,7 @@ function resizeKeepAspectRatio(width, height, maxWidth) { /** * Create a new image relationship and add it to the relationships array * - * @param {ExportParams} params + * @param {import('@converter/exporter').ExportParams} params * @param {string} imagePath The path to the image * @returns {string} The new relationship ID */ @@ -327,7 +318,7 @@ function addNewImageRelationship(params, imagePath) { /** * Create a new image relationship for export from collaborator's editor * - * @param {ExportParams} params + * @param {import('@converter/exporter').ExportParams} params * @param {string} id The new relationship ID * @param {string} imagePath The path to the image */ @@ -346,8 +337,8 @@ function addImageRelationshipForId(params, id, imagePath) { /** * Translates a vectorShape node back to XML. - * @param {Object} params - Translation parameters - * @returns {Object} XML node + * @param {import('@converter/exporter').ExportParams} params - Translation parameters + * @returns {import('@converter/v2/types').OpenXmlNode} XML node */ export function translateVectorShape(params) { const { node } = params; @@ -374,8 +365,8 @@ export function translateVectorShape(params) { /** * Translates a shapeGroup node back to XML. - * @param {Object} params - Translation parameters - * @returns {Object} XML node + * @param {import('@converter/exporter').ExportParams} params - Translation parameters + * @returns {import('@converter/v2/types').OpenXmlNode} XML node */ export function translateShapeGroup(params) { const { node } = params; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/textbox-content-helpers.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/textbox-content-helpers.js index fd25a38ed9..3df11bf5db 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/textbox-content-helpers.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/textbox-content-helpers.js @@ -54,11 +54,11 @@ export function collectTextBoxParagraphs(nodes, paragraphs = []) { * For header/footer files, uses simplified page field processing. * For body content, uses full field character processing. * - * @param {Object} textBoxContent - The w:txbxContent element containing paragraphs + * @param {import('@converter/v2/types').OpenXmlNode | undefined} textBoxContent - The w:txbxContent element containing paragraphs * @param {Object} [params={}] - Translator params * @param {Object} [params.docx] - The parsed docx object * @param {string} [params.filename] - The source filename (e.g., 'header1.xml', 'document.xml') - * @returns {Object} Processed text box content with field codes converted to sd:* nodes + * @returns {import('@converter/v2/types').OpenXmlNode | undefined} Processed text box content with field codes converted to sd:* nodes */ export function preProcessTextBoxContent(textBoxContent, params = {}) { if (!textBoxContent?.elements) return textBoxContent; diff --git a/packages/super-editor/src/core/super-converter/v3/node-translator/node-translator.js b/packages/super-editor/src/core/super-converter/v3/node-translator/node-translator.js deleted file mode 100644 index 6915bd6ab1..0000000000 --- a/packages/super-editor/src/core/super-converter/v3/node-translator/node-translator.js +++ /dev/null @@ -1,227 +0,0 @@ -// @ts-check - -/** - * @enum {string} - */ -export const TranslatorTypes = Object.freeze({ - NODE: 'node', - ATTRIBUTE: 'attribute', -}); - -/** - * @typedef {keyof typeof TranslatorTypes} TranslatorTypeKey - * @typedef {typeof TranslatorTypes[TranslatorTypeKey]} TranslatorType - * @typedef {string} XmlNodeName - * @typedef {string|string[]} SuperDocNodeOrKeyName - */ - -/** - * @typedef {Object} AttrConfig - * @property {string} xmlName - The name of the attribute in OOXML - * @property {string} sdName - The name of the attribute in SuperDoc - * @property {Function} [encode] - Function to encode the attribute from OOXML to SuperDoc - * @property {Function} [decode] - Function to decode the attribute from SuperDoc to OOXML - */ - -/** @typedef {import('../../v2/importer/types').NodeHandlerParams} SCEncoderConfig */ -/** @typedef {import('../../v2/types').SuperDocNode} SCEncoderResult */ -/** @typedef {import('@superdoc/common').Comment} Comment */ -/** - * @typedef {Object} SCDecoderConfig - * @property {{ attrs?: any, marks?: any[], type: string, content?: any[], text?: string }} node - * @property {any[]} [children] - * @property {any[]} [relationships] - * @property {Comment[]} [comments] - * @property {'external' | 'clean'} [commentsExportType] - * @property {any[]} [exportedCommentDefs] - * @property {Record} [extraParams] - * @property {import('../../../Editor.js').Editor} [editor] - */ -/** @typedef {{ name: string, attributes?: any, elements: any[] }} SCDecoderResult */ - -/** - * @callback NodeTranslatorEncodeFn - * @param {SCEncoderConfig} params - * @param {EncodedAttributes} [encodedAttrs] - * @returns {import('../../v2/types').SuperDocNode} - */ - -/** - * @callback NodeTranslatorDecodeFn - * @param {SCDecoderConfig} params - * @param {DecodedAttributes} [decodedAttrs] - * @returns {import('../../v2/types').OpenXmlNode | import('../../v2/types').OpenXmlNode[]} - */ - -/** @callback MatchesEncodeFn @param {any[]} nodes @param {any} [ctx] @returns {boolean} */ -/** @callback MatchesDecodeFn @param {any} node @param {any} [ctx] @returns {boolean} */ - -/** - * @typedef {Object} EncodedAttributes - */ - -/** - * @typedef {Object} DecodedAttributes - */ - -/** - * @typedef {Object} NodeTranslatorConfig - * @property {string} xmlName - The name of the node in OOXML - * @property {SuperDocNodeOrKeyName} sdNodeOrKeyName - The name of the node in SuperDoc - * @property {TranslatorType} [type="node"] - The type of the translator. - * @property {NodeTranslatorEncodeFn} encode - The function to encode the data. - * @property {NodeTranslatorDecodeFn} [decode] - The function to decode the data. - * @property {number} [priority] - The priority of the handler. - * @property {AttrConfig[]} [attributes] - Attribute handlers list. - * @property {MatchesEncodeFn} [matchesEncode] - The function to check if the handler can encode the data. - * @property {MatchesDecodeFn} [matchesDecode] - The function to check if the handler can decode the data. - */ - -export class NodeTranslator { - /** @type {string} */ - xmlName; - - /** @type {SuperDocNodeOrKeyName} */ - sdNodeOrKeyName; - - /** @type {number} */ - priority; - - /** @type {NodeTranslatorEncodeFn} */ - encodeFn; - - /** @type {NodeTranslatorDecodeFn} */ - decodeFn; - - /** @type {MatchesEncodeFn} */ - matchesEncode; - - /** @type {MatchesDecodeFn} */ - matchesDecode; - - /** @type {typeof TranslatorTypes} */ - static translatorTypes = TranslatorTypes; - - /** @type {AttrConfig[]} */ - attributes; - - /** - * @param {string} xmlName - * @param {SuperDocNodeOrKeyName} sdNodeOrKeyName - * @param {NodeTranslatorEncodeFn} encode - * @param {NodeTranslatorDecodeFn} decode - * @param {number} [priority] - * @param {MatchesEncodeFn} [matchesEncode] - * @param {MatchesDecodeFn} [matchesDecode] - * @param {AttrConfig[]} [attributes] - */ - constructor(xmlName, sdNodeOrKeyName, encode, decode, priority, matchesEncode, matchesDecode, attributes) { - this.xmlName = xmlName; - this.sdNodeOrKeyName = sdNodeOrKeyName; - - this.encodeFn = encode; - this.decodeFn = decode; - this.attributes = attributes || []; - - this.priority = typeof priority === 'number' ? priority : 0; - - this.matchesEncode = typeof matchesEncode === 'function' ? matchesEncode : () => true; - this.matchesDecode = typeof matchesDecode === 'function' ? matchesDecode : () => true; - } - - /** - * Encode the attributes for the node. - * @param {SCEncoderConfig} params - * @returns {Object} Encoded attributes object. - */ - encodeAttributes(params) { - const { nodes = [] } = params || {}; - const node = nodes[0]; - const { attributes = {} } = node || {}; - - const encodedAttrs = {}; - this.attributes.forEach(({ sdName, encode }) => { - if (!encode) return; - - const encodedAttr = encode(attributes); - if (encodedAttr !== undefined && encodedAttr !== null) { - encodedAttrs[sdName] = encodedAttr; - } - }); - - return encodedAttrs; - } - - /** - * Decode the attributes for the node. - * @param {SCDecoderConfig} params - * @returns {Object} Decoded attributes object. - */ - decodeAttributes(params) { - const { node } = params || {}; - const { attrs = {} } = node || {}; - - const decodedAttrs = {}; - this.attributes.forEach(({ xmlName, decode }) => { - if (!decode) return; - - const decodedAttr = decode(attrs); - if (decodedAttr !== undefined && decodedAttr !== null) { - decodedAttrs[xmlName] = decodedAttr; - } - }); - - return decodedAttrs; - } - - /** - * Decode the attributes for the node. - * @param {SCDecoderConfig} params - * @returns {Object} Decoded attributes object. - */ - decode(params) { - const decodedAttrs = this.decodeAttributes(params); - return this.decodeFn ? this.decodeFn.call(this, params, decodedAttrs) : undefined; - } - - /** - * Encode the attributes for the node. - * @param {SCEncoderConfig} params - * @returns {Object} Encoded attributes object. - */ - encode(params) { - const encodedAttrs = this.encodeAttributes(params); - return this.encodeFn ? this.encodeFn.call(this, params, encodedAttrs) : undefined; - } - - /** - * Create a new NodeTranslator instance from a configuration object. - * @param {NodeTranslatorConfig} config - The configuration object. - * @returns {NodeTranslator} The created NodeTranslator instance. - */ - static from(config) { - const { xmlName, sdNodeOrKeyName, encode, decode, priority = 0, matchesEncode, matchesDecode, attributes } = config; - if (typeof encode !== 'function' || (!!decode && typeof decode !== 'function')) { - throw new TypeError(`${xmlName}: encode/decode must be functions`); - } - const inst = new NodeTranslator( - xmlName, - sdNodeOrKeyName, - encode, - decode, - priority, - matchesEncode, - matchesDecode, - attributes, - ); - return Object.freeze(inst); - } - - /** - * Convert the NodeTranslator instance to a string representation. - * @returns {string} - The string representation of the NodeTranslator instance. - */ - toString() { - return `NodeTranslator(${this.xmlName}, priority=${this.priority})`; - } -} diff --git a/packages/super-editor/src/core/super-converter/v3/node-translator/node-translator.ts b/packages/super-editor/src/core/super-converter/v3/node-translator/node-translator.ts new file mode 100644 index 0000000000..409c5f61ea --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/node-translator/node-translator.ts @@ -0,0 +1,235 @@ +import type { NodeHandlerParams } from '@converter/v2/importer/types'; +import type { OpenXmlNode, SuperDocNode } from '@converter/v2/types'; +import type { Editor } from '@core/Editor'; +import type { Comment } from '@superdoc/common'; + +export type { Comment } from '@superdoc/common'; + +/** + * @enum {string} + */ +export const TranslatorTypes = Object.freeze({ + NODE: 'node', + ATTRIBUTE: 'attribute', +}); + +export type TranslatorTypeKey = keyof typeof TranslatorTypes; +export type TranslatorType = (typeof TranslatorTypes)[TranslatorTypeKey]; +export type XmlNodeName = string; +export type SuperDocNodeOrKeyName = string | string[]; + +export type AttrConfig = { + /** The name of the attribute in OOXML */ + xmlName: string; + + /** The name of the attribute in SuperDoc */ + sdName: string; + + /** Function to encode the attribute from OOXML to SuperDoc */ + encode: (...args: any[]) => any; + + /** Function to decode the attribute from SuperDoc to OOXML */ + decode: (...args: any[]) => any; +}; + +export type SCEncoderConfig = NodeHandlerParams; +export type SCEncoderResult = SuperDocNode; + +export type SCDecoderConfig = { + node: { + attrs?: any; + marks?: any[]; + type: string; + content?: any[]; + text?: string; + }; + + children: any[]; + relationships: any[]; + comments: Comment[]; + commentsExporter: 'external' | 'clean'; + commentsExportType?: any; + exportedCommentDefs: any[]; + extraParams: Record; + editor: Editor; +}; + +export type SCDecoderResult = OpenXmlNode | OpenXmlNode[] | undefined; + +export type NodeTranslatorEncodeFn = ( + params: SCEncoderConfig, + encodedAttrs?: EncodedAttributes, +) => TEncodeResult; +export type NodeTranslatorDecodeFn = ( + params: SCDecoderConfig, + decodedAttrs?: DecodedAttributes, +) => TDecodeResult; +export type MatchesEncodeFn = (nodes: any[], ctx?: any) => boolean; +export type MatchesDecodeFn = (node: any, ctx?: any) => boolean; + +export type EncodedAttributes = Record; +export type DecodedAttributes = Record; + +export type NodeTranslatorConfig< + TDecodeResult extends SCDecoderResult = SCDecoderResult, + TEncodeResult extends SCEncoderResult = SCEncoderResult, +> = { + /** The name of the node in OOXML */ + xmlName: string; + /** The name of the node in SuperDoc */ + sdNodeOrKeyName: SuperDocNodeOrKeyName; + /** The type of the translator. */ + type?: TranslatorType; + /** The function to encode the data. */ + encode: NodeTranslatorEncodeFn; + /** The function to decode the data. */ + decode: NodeTranslatorDecodeFn; + /** The priority of the handler. */ + priority?: number; + /** Attribute handlers list. */ + attributes?: AttrConfig[]; + /** The function to check if the handler can encode the data. */ + matchesEncode?: MatchesEncodeFn; + /** The function to check if the handler can decode the data. */ + matchesDecode?: MatchesDecodeFn; +}; + +export class NodeTranslator< + TDecodeResult extends SCDecoderResult = SCDecoderResult, + TEncodeResult extends SCEncoderResult = SCEncoderResult, +> { + xmlName: string; + + sdNodeOrKeyName: SuperDocNodeOrKeyName; + + priority: number; + + encodeFn: NodeTranslatorEncodeFn; + + decodeFn: NodeTranslatorDecodeFn; + + matchesEncode: MatchesEncodeFn; + + matchesDecode: MatchesDecodeFn; + + static translatorTypes: typeof TranslatorTypes = TranslatorTypes; + + attributes: AttrConfig[]; + + constructor( + xmlName: string, + sdNodeOrKeyName: SuperDocNodeOrKeyName, + encode: NodeTranslatorEncodeFn, + decode: NodeTranslatorDecodeFn, + priority: number, + matchesEncode?: MatchesEncodeFn, + matchesDecode?: MatchesDecodeFn, + attributes?: AttrConfig[], + ) { + this.xmlName = xmlName; + this.sdNodeOrKeyName = sdNodeOrKeyName; + + this.encodeFn = encode ?? (() => undefined); + this.decodeFn = decode ?? (() => undefined); + this.attributes = attributes || []; + + this.priority = typeof priority === 'number' ? priority : 0; + + this.matchesEncode = typeof matchesEncode === 'function' ? matchesEncode : () => true; + this.matchesDecode = typeof matchesDecode === 'function' ? matchesDecode : () => true; + } + + /** + * Encode the attributes for the node. + * @returns - Encoded attributes object. + */ + encodeAttributes(params: SCEncoderConfig): object { + const { nodes = [] } = params || {}; + const node = nodes[0]; + const { attributes = {} } = node || {}; + + const encodedAttrs: Record = {}; + this.attributes.forEach(({ sdName, encode }) => { + if (!encode) return; + + const encodedAttr = encode(attributes); + if (encodedAttr !== undefined && encodedAttr !== null) { + encodedAttrs[sdName] = encodedAttr; + } + }); + + return encodedAttrs; + } + + /** + * Decode the attributes for the node. + * @returns - Decoded attributes object. + */ + decodeAttributes(params: SCDecoderConfig): object { + const { node } = params || {}; + const { attrs = {} } = node || {}; + + const /** @type Record */ decodedAttrs: Record = {}; + this.attributes.forEach(({ xmlName, decode }) => { + if (!decode) return; + + const decodedAttr = decode(attrs); + if (decodedAttr !== undefined && decodedAttr !== null) { + decodedAttrs[xmlName] = decodedAttr; + } + }); + + return decodedAttrs; + } + + /** + * Decode the attributes for the node. + * @returns - Decoded attributes object. + */ + decode(params: SCDecoderConfig): TDecodeResult { + const decodedAttrs = this.decodeAttributes(params); + return this.decodeFn.call(this, params, decodedAttrs); + } + + /** + * Encode the attributes for the node. + * @returns - Encoded attributes object. + */ + encode(params: SCEncoderConfig): TEncodeResult { + const encodedAttrs = this.encodeAttributes(params); + return this.encodeFn.call(this, params, encodedAttrs); + } + + /** + * Create a new NodeTranslator instance from a configuration object. + * @param config - The configuration object. + * @returns - The created NodeTranslator instance. + */ + static from( + config: NodeTranslatorConfig, + ): NodeTranslator { + const { xmlName, sdNodeOrKeyName, encode, decode, priority = 0, matchesEncode, matchesDecode, attributes } = config; + if (typeof encode !== 'function' || (!!decode && typeof decode !== 'function')) { + throw new TypeError(`${xmlName}: encode/decode must be functions`); + } + const inst = new NodeTranslator( + xmlName, + sdNodeOrKeyName, + encode, + decode, + priority, + matchesEncode, + matchesDecode, + attributes, + ); + return Object.freeze(inst); + } + + /** + * Convert the NodeTranslator instance to a string representation. + * @returns {string} - The string representation of the NodeTranslator instance. + */ + toString(): string { + return `NodeTranslator(${this.xmlName}, priority=${this.priority})`; + } +} diff --git a/packages/super-editor/src/extensions/image/image.js b/packages/super-editor/src/extensions/image/image.js index 738cb3741e..ef7f23ff7f 100644 --- a/packages/super-editor/src/extensions/image/image.js +++ b/packages/super-editor/src/extensions/image/image.js @@ -5,6 +5,7 @@ import { getNormalizedImageAttrs } from './imageHelpers/legacyAttributes.js'; import { getRotationMargins } from './imageHelpers/rotation.js'; import { inchesToPixels } from '@converter/helpers.js'; import { OOXML_Z_INDEX_BASE } from '@extensions/shared/constants.js'; +import { render } from 'vue'; /** * Configuration options for Image @@ -41,6 +42,10 @@ import { OOXML_Z_INDEX_BASE } from '@extensions/shared/constants.js'; * @property {Object} [anchorData] @internal Anchor positioning data for Word * @property {boolean} [isAnchor] @internal Whether image is anchored * @property {boolean} [simplePos] @internal Simple positioning flag + * @property {boolean} [isPict] @internal Original tag was a w:pict + * @property {boolean} [vmlWatermark] @internal Set for pict images + * @property {Object} [vmlAttributes] @internal Set for pict images + * @property {Object} [vmlImagedata] @internal Set for pict images * @property {string} [wrapText] @internal Text wrapping style */ @@ -102,6 +107,8 @@ export const Image = Node.create({ id: { rendered: false }, + isPict: { rendered: false }, + hidden: { default: false, rendered: false, @@ -153,6 +160,9 @@ export const Image = Node.create({ }, isAnchor: { rendered: false }, + vmlWatermark: { rendered: false }, + vmlAttributes: { rendered: false }, + vmlImagedata: { rendered: false }, /** * @category Attribute diff --git a/packages/super-editor/src/tests/data/.gitignore b/packages/super-editor/src/tests/data/.gitignore deleted file mode 100644 index bfd09bf5cf..0000000000 --- a/packages/super-editor/src/tests/data/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -*/ - -!tab_stops_basic_test/ -!tab_stops_basic_test/** -!table_in_list/ -!table_in_list/** diff --git a/packages/super-editor/src/tests/data/image-round-trip/drawing-with-rotation/[Content_Types].xml b/packages/super-editor/src/tests/data/image-round-trip/drawing-with-rotation/[Content_Types].xml new file mode 100644 index 0000000000..7ed0652c06 --- /dev/null +++ b/packages/super-editor/src/tests/data/image-round-trip/drawing-with-rotation/[Content_Types].xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/super-editor/src/tests/data/image-round-trip/drawing-with-rotation/_rels/.rels b/packages/super-editor/src/tests/data/image-round-trip/drawing-with-rotation/_rels/.rels new file mode 100644 index 0000000000..610628fcd6 --- /dev/null +++ b/packages/super-editor/src/tests/data/image-round-trip/drawing-with-rotation/_rels/.rels @@ -0,0 +1,4 @@ + + + + diff --git a/packages/super-editor/src/tests/data/image-round-trip/drawing-with-rotation/word/_rels/document.xml.rels b/packages/super-editor/src/tests/data/image-round-trip/drawing-with-rotation/word/_rels/document.xml.rels new file mode 100644 index 0000000000..49d2947a22 --- /dev/null +++ b/packages/super-editor/src/tests/data/image-round-trip/drawing-with-rotation/word/_rels/document.xml.rels @@ -0,0 +1,4 @@ + + + + diff --git a/packages/super-editor/src/tests/data/image-round-trip/drawing-with-rotation/word/document.xml b/packages/super-editor/src/tests/data/image-round-trip/drawing-with-rotation/word/document.xml new file mode 100644 index 0000000000..2ac820f1eb --- /dev/null +++ b/packages/super-editor/src/tests/data/image-round-trip/drawing-with-rotation/word/document.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/super-editor/src/tests/data/image-round-trip/drawing-with-rotation/word/media/image1.png b/packages/super-editor/src/tests/data/image-round-trip/drawing-with-rotation/word/media/image1.png new file mode 100644 index 0000000000..de971190f6 Binary files /dev/null and b/packages/super-editor/src/tests/data/image-round-trip/drawing-with-rotation/word/media/image1.png differ diff --git a/packages/super-editor/src/tests/data/image-round-trip/drawing-with-rotation/word/styles.xml b/packages/super-editor/src/tests/data/image-round-trip/drawing-with-rotation/word/styles.xml new file mode 100644 index 0000000000..3e21c09b57 --- /dev/null +++ b/packages/super-editor/src/tests/data/image-round-trip/drawing-with-rotation/word/styles.xml @@ -0,0 +1,4 @@ + + + + diff --git a/packages/super-editor/src/tests/data/image-round-trip/pict-example/[Content_Types].xml b/packages/super-editor/src/tests/data/image-round-trip/pict-example/[Content_Types].xml new file mode 100644 index 0000000000..3fb5629619 --- /dev/null +++ b/packages/super-editor/src/tests/data/image-round-trip/pict-example/[Content_Types].xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/super-editor/src/tests/data/image-round-trip/pict-example/_rels/.rels b/packages/super-editor/src/tests/data/image-round-trip/pict-example/_rels/.rels new file mode 100644 index 0000000000..610628fcd6 --- /dev/null +++ b/packages/super-editor/src/tests/data/image-round-trip/pict-example/_rels/.rels @@ -0,0 +1,4 @@ + + + + diff --git a/packages/super-editor/src/tests/data/image-round-trip/pict-example/word/_rels/document.xml.rels b/packages/super-editor/src/tests/data/image-round-trip/pict-example/word/_rels/document.xml.rels new file mode 100644 index 0000000000..7ca9fcec53 --- /dev/null +++ b/packages/super-editor/src/tests/data/image-round-trip/pict-example/word/_rels/document.xml.rels @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/super-editor/src/tests/data/image-round-trip/pict-example/word/document.xml b/packages/super-editor/src/tests/data/image-round-trip/pict-example/word/document.xml new file mode 100644 index 0000000000..f83d7bd50e --- /dev/null +++ b/packages/super-editor/src/tests/data/image-round-trip/pict-example/word/document.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/super-editor/src/tests/data/image-round-trip/pict-example/word/media/image1.png b/packages/super-editor/src/tests/data/image-round-trip/pict-example/word/media/image1.png new file mode 100644 index 0000000000..7595acb23d Binary files /dev/null and b/packages/super-editor/src/tests/data/image-round-trip/pict-example/word/media/image1.png differ diff --git a/packages/super-editor/src/tests/data/image-round-trip/pict-example/word/media/image2.png b/packages/super-editor/src/tests/data/image-round-trip/pict-example/word/media/image2.png new file mode 100644 index 0000000000..666d51302c Binary files /dev/null and b/packages/super-editor/src/tests/data/image-round-trip/pict-example/word/media/image2.png differ diff --git a/packages/super-editor/src/tests/data/image-round-trip/pict-example/word/styles.xml b/packages/super-editor/src/tests/data/image-round-trip/pict-example/word/styles.xml new file mode 100644 index 0000000000..c4eba260e2 --- /dev/null +++ b/packages/super-editor/src/tests/data/image-round-trip/pict-example/word/styles.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/packages/super-editor/src/tests/export/lists/list-table-spacing.test.js b/packages/super-editor/src/tests/export/lists/list-table-spacing.test.js index ddaf7f9d7b..8acc353ea6 100644 --- a/packages/super-editor/src/tests/export/lists/list-table-spacing.test.js +++ b/packages/super-editor/src/tests/export/lists/list-table-spacing.test.js @@ -1,7 +1,6 @@ import { describe, it, expect } from 'vitest'; import { getExportedResult } from '@tests/export/export-helpers/index.js'; - -const findFirst = (elements, name) => elements?.find((element) => element.name === name); +import { findFirstChild } from '@tests/helpers/finders.js'; const collectRunsWithBreak = (paragraph) => { if (!paragraph?.elements) return []; @@ -21,13 +20,13 @@ describe('list item tables', () => { it('does not emit a manual line break before a table in a list item', async () => { const exportResult = await getExportedResult('list-with-table-break.docx'); - const body = findFirst(exportResult.elements, 'w:body'); + const body = findFirstChild(exportResult, 'w:body'); expect(body).toBeDefined(); - const paragraph = findFirst(body.elements, 'w:p'); + const paragraph = findFirstChild(body, 'w:p'); expect(paragraph).toBeDefined(); - const table = findFirst(body.elements, 'w:tbl'); + const table = findFirstChild(body, 'w:tbl'); expect(table).toBeDefined(); const runsWithBreak = collectRunsWithBreak(paragraph); diff --git a/packages/super-editor/src/tests/helpers/finders.ts b/packages/super-editor/src/tests/helpers/finders.ts new file mode 100644 index 0000000000..bb82ec17e0 --- /dev/null +++ b/packages/super-editor/src/tests/helpers/finders.ts @@ -0,0 +1,36 @@ +import type { Element } from 'xml-js'; + +export const findFirstChild = (element: Element, name: string): Element | undefined => + element.elements?.find((element) => element.name === name); + +export const findFirstDescendant = ( + element: Element, + name: string, + allowSelf: boolean = false, +): Element | undefined => { + if (allowSelf && element.name === name) { + return element; + } + if (element.elements) { + for (const child of element.elements) { + let result = findFirstDescendant(child, name, true); + if (result) { + return result; + } + } + } +}; + +export const findAllDescendants = (element: Element, name: string, allowSelf: boolean = false): Element[] => { + const result: Element[] = []; + + if (allowSelf && element.name === name) { + result.push(element); + } + if (element.elements) { + for (const child of element.elements) { + result.push(...findAllDescendants(child, name, true)); + } + } + return result; +}; diff --git a/packages/super-editor/src/tests/helpers/loadUnpackedDocx.ts b/packages/super-editor/src/tests/helpers/loadUnpackedDocx.ts new file mode 100644 index 0000000000..6b8cde6c60 --- /dev/null +++ b/packages/super-editor/src/tests/helpers/loadUnpackedDocx.ts @@ -0,0 +1,12 @@ +import { Editor } from '@core/Editor.js'; +import { zipFolderToBuffer } from './zipFolderToBuffer.js'; + +/** + * Load an unpacked docx from a directory + * @param dirName path to the directory + */ +export async function loadUnpackedDocx(dirName: string): ReturnType { + const buffer = await zipFolderToBuffer(dirName); + + return Editor.loadXmlData(buffer, true); +} diff --git a/packages/super-editor/src/tests/helpers/pict-helpers.ts b/packages/super-editor/src/tests/helpers/pict-helpers.ts new file mode 100644 index 0000000000..08b41747e6 --- /dev/null +++ b/packages/super-editor/src/tests/helpers/pict-helpers.ts @@ -0,0 +1,21 @@ +export const createPict = (elements = []) => ({ + name: 'v:pict', + elements, +}); + +export const createRect = () => ({ + name: 'v:rect', +}); + +export const createShape = (elements = []) => ({ + name: 'v:shape', + elements, +}); + +export const createGroup = () => ({ + name: 'v:group', +}); + +export const createTextbox = () => ({ + name: 'v:textbox', +}); diff --git a/packages/super-editor/src/tests/helpers/rels-helpers.ts b/packages/super-editor/src/tests/helpers/rels-helpers.ts new file mode 100644 index 0000000000..c61d782217 --- /dev/null +++ b/packages/super-editor/src/tests/helpers/rels-helpers.ts @@ -0,0 +1,28 @@ +type CreateRelsProps = { + filename: string; + rels: Record; +}; + +/** + * Create the rels entry for a docx directory + * @param filename - filename of the word document (defaults to document.xml) + * @param rels - record mapping rId keys to target filename values + */ +export function createRels({ filename = 'document.xml', rels }: CreateRelsProps) { + return { + [`word/_rels/${filename}.rels`]: { + elements: [ + { + name: 'Relationships', + elements: Object.entries(rels).map(([key, value]) => ({ + name: 'Relationship', + attributes: { + Id: key, + Target: value, + }, + })), + }, + ], + }, + }; +} diff --git a/packages/super-editor/src/tests/import-export/imageRoundTrip.test.js b/packages/super-editor/src/tests/import-export/imageRoundTrip.test.js index 8bcfb37f70..17e5558055 100644 --- a/packages/super-editor/src/tests/import-export/imageRoundTrip.test.js +++ b/packages/super-editor/src/tests/import-export/imageRoundTrip.test.js @@ -1,110 +1,46 @@ -import { describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest'; -import { handleDrawingNode, handleImageImport } from '../../core/super-converter/v2/importer/imageImporter.js'; +import { describe, it, expect, beforeAll } from 'vitest'; +import { handleImageImport } from '@converter/v2/importer/imageImporter.js'; +import DocxZipper from '@core/DocxZipper.js'; import { exportSchemaToJson } from '@converter/exporter'; import { emuToPixels, rotToDegrees, pixelsToEmu, degreesToRot } from '@converter/helpers'; -import { createDocumentJson } from '@core/super-converter/v2/importer/docxImporter.js'; -import { getTestDataByFileName } from '@tests/helpers/helpers.js'; +import { docxAsXmlFileList2ParsedDocx } from '@converter/v2/docxHelper.js'; +import { createDocumentJson } from '@converter/v2/importer/docxImporter.js'; +import { getTestDataByFileName, initTestEditor } from '@tests/helpers/helpers.js'; +import { loadUnpackedDocx } from '@tests/helpers/loadUnpackedDocx.js'; +import { findAllDescendants, findFirstChild, findFirstDescendant } from '@tests/helpers/finders.js'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { writeFileSync } from 'node:fs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); describe('Image Import/Export Round Trip Tests', () => { describe('Transform Data Round Trip', () => { - it('preserves rotation data through import/export cycle', () => { + it('preserves rotation data through import/export cycle', async () => { // Mock OOXML input with rotation - const mockXmlInput = { - name: 'wp:inline', - attributes: { - distT: '0', - distB: '0', - distL: '0', - distR: '0', - }, - elements: [ - { - name: 'wp:extent', - attributes: { cx: '3810000', cy: '2857500' }, - }, - { - name: 'wp:effectExtent', - attributes: { l: '0', t: '0', r: '0', b: '0' }, - }, - { - name: 'a:graphic', - elements: [ - { - name: 'a:graphicData', - attributes: { uri: 'http://schemas.openxmlformats.org/drawingml/2006/picture' }, - elements: [ - { - name: 'pic:pic', - elements: [ - { - name: 'pic:blipFill', - elements: [ - { - name: 'a:blip', - attributes: { 'r:embed': 'rId1' }, - }, - ], - }, - { - name: 'pic:spPr', - elements: [ - { - name: 'a:xfrm', - attributes: { - rot: '1800000', // 30 degrees - flipV: '1', - }, - }, - ], - }, - ], - }, - ], - }, - ], - }, - { - name: 'wp:docPr', - attributes: { id: '1', name: 'Test Image' }, - }, - ], - }; - - const mockDocx = { - 'word/_rels/document.xml.rels': { - elements: [ - { - name: 'Relationships', - elements: [ - { - attributes: { - Id: 'rId1', - Target: 'media/test-image.jpg', - }, - }, - ], - }, - ], - }, - }; + const [docx, media, mediaFiles, fonts] = await loadUnpackedDocx( + join(__dirname, '../data/image-round-trip/drawing-with-rotation'), + ); // Import - const params = { docx: mockDocx }; - const importedImage = handleImageImport(mockXmlInput, null, params); + const { editor } = await initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + const images = editor.getNodesOfType('image'); + expect(images).toHaveLength(1); + const importedImage = images[0].node.toJSON(); // Verify import expect(importedImage.type).toBe('image'); expect(importedImage.attrs.transformData.rotation).toBe(rotToDegrees('1800000')); expect(importedImage.attrs.transformData.verticalFlip).toBe(true); - // Create mock export params - const mockExportParams = { - node: importedImage, - relationships: [], - }; - // Export - const exportedResult = exportSchemaToJson(mockExportParams); + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + expect(exportedBuffer?.byteLength || exportedBuffer?.length || 0).toBeGreaterThan(0); + const exportedZipper = new DocxZipper(); + const exportedFiles = await exportedZipper.getDocxData(exportedBuffer, true); + const exportedJson = docxAsXmlFileList2ParsedDocx(exportedFiles); + const exportedDocument = exportedJson['word/document.xml']; + const exportedResult = findFirstDescendant(exportedDocument, 'w:r'); // Verify export structure expect(exportedResult.name).toBe('w:r'); @@ -126,6 +62,7 @@ describe('Image Import/Export Round Trip Tests', () => { }); it('preserves horizontal flip through round trip', () => { + // TODO: move sample data into an external file, and test full round-trip as done for the rotation test above const mockXmlInput = { name: 'wp:inline', attributes: { distT: '0', distB: '0', distL: '0', distR: '0' }, @@ -219,6 +156,7 @@ describe('Image Import/Export Round Trip Tests', () => { }); it('preserves size extensions through round trip', () => { + // TODO: move sample data into an external file, and test full round-trip as done for the rotation test above const mockXmlInput = { name: 'wp:inline', attributes: { distT: '0', distB: '0', distL: '0', distR: '0' }, @@ -316,6 +254,7 @@ describe('Image Import/Export Round Trip Tests', () => { }); it('handles combined transformations correctly', () => { + // TODO: move sample data into an external file, and test full round-trip as done for the rotation test above const mockXmlInput = { name: 'wp:inline', attributes: { distT: '0', distB: '0', distL: '0', distR: '0' }, @@ -430,6 +369,7 @@ describe('Image Import/Export Round Trip Tests', () => { describe('Basic Image Properties Round Trip', () => { it('preserves basic image attributes', () => { + // TODO: move sample data into an external file, and test full round-trip as done for the rotation test above const mockXmlInput = { name: 'wp:inline', attributes: { @@ -586,6 +526,7 @@ describe('Image Import/Export Round Trip Tests', () => { describe('Error Handling in Round Trip', () => { it('handles missing transform attributes gracefully', () => { + // TODO: move sample data into an external file, and test full round-trip as done for the rotation test above const mockXmlInput = { name: 'wp:inline', attributes: { distT: '0', distB: '0', distL: '0', distR: '0' }, @@ -664,6 +605,7 @@ describe('Image Import/Export Round Trip Tests', () => { }); it('handles malformed size extension data gracefully', () => { + // TODO: move sample data into an external file, and test full round-trip as done for the rotation test above const mockXmlInput = { name: 'wp:inline', attributes: { distT: '0', distB: '0', distL: '0', distR: '0' }, @@ -850,4 +792,54 @@ describe('Image Import/Export Round Trip Tests', () => { }); }); }); + + describe('Images as w:pict elements', () => { + it('are preserved as w:pict rather than converted to w:drawing', async () => { + const [docx, media, mediaFiles, fonts] = await loadUnpackedDocx( + join(__dirname, '../data/image-round-trip/pict-example'), + ); + + // Import + const { editor } = await initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + const images = editor.getNodesOfType('image'); + // expect(images).toHaveLength(2); + + const [importedPict1, importedPict2] = images.map(({ node }) => node.toJSON()); + + // Verify import + expect(importedPict1.type).toBe('image'); + expect(importedPict1.attrs.src).toBe('word/media/image1.png'); + expect(importedPict1.attrs.title).toBe('Watermark'); + + expect(importedPict2.type).toBe('image'); + expect(importedPict2.attrs.src).toBe('word/media/image2.png'); + expect(importedPict2.attrs.title).toBe('Watermark'); + + // Export + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + expect(exportedBuffer?.byteLength || exportedBuffer?.length || 0).toBeGreaterThan(0); + const exportedZipper = new DocxZipper(); + const exportedFiles = await exportedZipper.getDocxData(exportedBuffer, true); + const exportedJson = docxAsXmlFileList2ParsedDocx(exportedFiles); + const exportedDocument = exportedJson['word/document.xml']; + const exportedRuns = findAllDescendants(exportedDocument, 'w:r'); + expect(exportedRuns).toHaveLength(2); + + const [r1, r2] = exportedRuns; + + // Verify export structure + const pict1 = findFirstChild(r1, 'w:pict'); + expect(pict1).toBeDefined(); + expect(pict1.elements.map((el) => el.name)).toEqual(['v:shape', 'v:shapetype']); + const [shape1, shapetype1] = pict1.elements; + expect(shape1.elements.map((el) => el.name)).toEqual(['v:imagedata']); + expect(shapetype1.elements.map((el) => el.name)).toEqual(['v:stroke', 'v:formulas', 'v:path', 'o:lock']); + + const pict2 = findFirstChild(r2, 'w:pict'); + expect(pict2).toBeDefined(); + expect(pict2.elements.map((el) => el.name)).toEqual(['v:shape']); + const shape2 = pict2.elements[0]; + expect(shape2.elements.map((el) => el.name)).toEqual(['v:imagedata']); + }); + }); }); diff --git a/packages/super-editor/src/tests/import-export/tabStopsBasicRoundtrip.test.js b/packages/super-editor/src/tests/import-export/tabStopsBasicRoundtrip.test.js index 4783d539ea..e5bca42aaf 100644 --- a/packages/super-editor/src/tests/import-export/tabStopsBasicRoundtrip.test.js +++ b/packages/super-editor/src/tests/import-export/tabStopsBasicRoundtrip.test.js @@ -1,36 +1,13 @@ import { describe, it, expect } from 'vitest'; import { dirname, join } from 'path'; -import { promises as fs } from 'fs'; import { fileURLToPath } from 'node:url'; -import JSZip from 'jszip'; -import { Editor } from '@core/Editor.js'; import DocxZipper from '@core/DocxZipper.js'; import { parseXmlToJson } from '@converter/v2/docxHelper.js'; import { initTestEditor } from '../helpers/helpers.js'; +import { loadUnpackedDocx } from '../helpers/loadUnpackedDocx.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const zipFolderToBuffer = async (folderPath) => { - const zip = new JSZip(); - - const addFolder = async (basePath, targetFolder) => { - const entries = await fs.readdir(basePath, { withFileTypes: true }); - for (const entry of entries) { - const absolute = join(basePath, entry.name); - if (entry.isDirectory()) { - const nested = targetFolder.folder(entry.name); - await addFolder(absolute, nested); - } else { - const content = await fs.readFile(absolute); - targetFolder.file(entry.name, content); - } - } - }; - - await addFolder(folderPath, zip); - return zip.generateAsync({ type: 'nodebuffer' }); -}; - const findParagraph = (elements, index) => { let count = -1; for (const element of elements) { @@ -42,8 +19,7 @@ const findParagraph = (elements, index) => { describe('tab_stops_basic_test roundtrip', () => { it('exports custom tab stops as left/right aligned tabs', async () => { - const buffer = await zipFolderToBuffer(join(__dirname, '../data/tab_stops_basic_test')); - const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); + const [docx, media, mediaFiles, fonts] = await loadUnpackedDocx(join(__dirname, '../data/tab_stops_basic_test')); const { editor } = await initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); diff --git a/packages/super-editor/src/tests/import-export/tableInListRoundtrip.test.js b/packages/super-editor/src/tests/import-export/tableInListRoundtrip.test.js index 16dc601b79..fc8585b8d4 100644 --- a/packages/super-editor/src/tests/import-export/tableInListRoundtrip.test.js +++ b/packages/super-editor/src/tests/import-export/tableInListRoundtrip.test.js @@ -1,37 +1,14 @@ import { describe, it, expect } from 'vitest'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'path'; -import { promises as fs } from 'fs'; -import JSZip from 'jszip'; -import { Editor } from '@core/Editor.js'; import DocxZipper from '@core/DocxZipper.js'; import { parseXmlToJson } from '@converter/v2/docxHelper.js'; import { initTestEditor } from '../helpers/helpers.js'; +import { loadUnpackedDocx } from '../helpers/loadUnpackedDocx.js'; +import { findFirstChild } from '@tests/helpers/finders.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const zipFolderToBuffer = async (folderPath) => { - const zip = new JSZip(); - - const addFolder = async (basePath, targetFolder) => { - const entries = await fs.readdir(basePath, { withFileTypes: true }); - for (const entry of entries) { - const absolute = join(basePath, entry.name); - if (entry.isDirectory()) { - const nestedFolder = targetFolder.folder(entry.name); - await addFolder(absolute, nestedFolder); - } else { - const content = await fs.readFile(absolute); - targetFolder.file(entry.name, content); - } - } - }; - - await addFolder(folderPath, zip); - return zip.generateAsync({ type: 'nodebuffer' }); -}; - -const findFirst = (elements = [], name) => elements.find((element) => element.name === name); const collectRunsWithBreak = (paragraph) => { if (!paragraph?.elements) return []; return paragraph.elements.filter( @@ -47,9 +24,7 @@ const findFirstTableCellParagraph = (table) => { describe('table_in_list roundtrip', () => { it('exports list/table structure with expected spacing and cell indent', async () => { const folderPath = join(__dirname, '../data/table_in_list'); - const buffer = await zipFolderToBuffer(folderPath); - - const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); + const [docx, media, mediaFiles, fonts] = await loadUnpackedDocx(folderPath); const { editor } = await initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); @@ -61,16 +36,16 @@ describe('table_in_list roundtrip', () => { expect(documentXmlEntry).toBeDefined(); const documentJson = parseXmlToJson(documentXmlEntry.content); - const documentNode = findFirst(documentJson.elements, 'w:document'); - const body = findFirst(documentNode?.elements, 'w:body'); + const documentNode = findFirstChild(documentJson, 'w:document'); + const body = findFirstChild(documentNode, 'w:body'); expect(body).toBeDefined(); - const firstParagraph = findFirst(body?.elements, 'w:p'); + const firstParagraph = findFirstChild(body, 'w:p'); expect(firstParagraph).toBeDefined(); const runsWithBreak = collectRunsWithBreak(firstParagraph); expect(runsWithBreak.length).toBe(1); - const firstTable = findFirst(body?.elements, 'w:tbl'); + const firstTable = findFirstChild(body, 'w:tbl'); expect(firstTable).toBeDefined(); const tableCellParagraph = findFirstTableCellParagraph(firstTable); diff --git a/packages/super-editor/src/tests/import/docxImporter.test.js b/packages/super-editor/src/tests/import/docxImporter.test.js index 13015c6fff..b86776a85c 100644 --- a/packages/super-editor/src/tests/import/docxImporter.test.js +++ b/packages/super-editor/src/tests/import/docxImporter.test.js @@ -213,6 +213,7 @@ describe('createDocumentJson', () => { const horizontalRules = (result.pmDoc.content || []) .filter((node) => node?.type === 'paragraph') .flatMap((paragraph) => paragraph?.content || []) + .flatMap((child) => child?.content || []) .filter((child) => child?.type === 'contentBlock' && child.attrs?.horizontalRule); expect(horizontalRules).toHaveLength(3); diff --git a/packages/super-editor/src/tests/import/rectImporter.test.js b/packages/super-editor/src/tests/import/rectImporter.test.js index 9a144f5abc..b80309554a 100644 --- a/packages/super-editor/src/tests/import/rectImporter.test.js +++ b/packages/super-editor/src/tests/import/rectImporter.test.js @@ -17,28 +17,18 @@ describe('RectImporter', () => { it('should handle v:rect elements and return contentBlock', () => { const nodes = [ { - name: 'w:p', + name: 'w:pict', elements: [ { - name: 'w:r', - elements: [ - { - name: 'w:pict', - elements: [ - { - name: 'v:rect', - attributes: { - style: 'width: 100pt; height: 2pt;', - fillcolor: '#000000', - 'o:hr': 't', - 'o:hralign': 'center', - 'o:hrstd': 't', - stroked: 'f', - }, - }, - ], - }, - ], + name: 'v:rect', + attributes: { + style: 'width: 100pt; height: 2pt;', + fillcolor: '#000000', + 'o:hr': 't', + 'o:hralign': 'center', + 'o:hrstd': 't', + stroked: 'f', + }, }, ], }, @@ -49,12 +39,9 @@ describe('RectImporter', () => { expect(result.nodes).toHaveLength(1); expect(result.consumed).toBe(1); - const node = result.nodes[0]; - expect(node.type).toBe('paragraph'); - expect(node.content).toHaveLength(1); - expect(node.content[0].type).toBe('contentBlock'); + const contentBlock = result.nodes[0]; + expect(contentBlock.type).toBe('contentBlock'); - const contentBlock = node.content[0]; expect(contentBlock.attrs.attributes).toBeDefined(); expect(contentBlock.attrs.style).toBeDefined(); expect(contentBlock.attrs.size).toBeDefined(); @@ -66,24 +53,14 @@ describe('RectImporter', () => { it('should handle v:rect elements without style attribute', () => { const nodes = [ { - name: 'w:p', + name: 'w:pict', elements: [ { - name: 'w:r', - elements: [ - { - name: 'w:pict', - elements: [ - { - name: 'v:rect', - attributes: { - fillcolor: '#FF0000', - 'o:hr': 't', - }, - }, - ], - }, - ], + name: 'v:rect', + attributes: { + fillcolor: '#FF0000', + 'o:hr': 't', + }, }, ], }, @@ -94,8 +71,7 @@ describe('RectImporter', () => { expect(result.nodes).toHaveLength(1); expect(result.consumed).toBe(1); - const node = result.nodes[0]; - const contentBlock = node.content[0]; + const contentBlock = result.nodes[0]; expect(contentBlock.attrs.attributes).toBeDefined(); expect(contentBlock.attrs.style).toBeUndefined(); expect(contentBlock.attrs.size).toBeUndefined(); @@ -103,80 +79,14 @@ describe('RectImporter', () => { expect(contentBlock.attrs.horizontalRule).toBe(true); }); - it('should handle v:rect elements with paragraph spacing', () => { - const nodes = [ - { - name: 'w:p', - attributes: { 'w:rsidRDefault': 'test123' }, - elements: [ - { - name: 'w:pPr', - elements: [ - { - name: 'w:spacing', - attributes: { - 'w:after': '240', // 12pt in twips - 'w:before': '120', // 6pt in twips - 'w:line': '276', // 1.15 line spacing - 'w:lineRule': 'auto', - }, - }, - ], - }, - { - name: 'w:r', - elements: [ - { - name: 'w:pict', - elements: [ - { - name: 'v:rect', - attributes: { - style: 'width: 50pt; height: 1pt;', - 'o:hr': 't', - }, - }, - ], - }, - ], - }, - ], - }, - ]; - - const result = handlePictNode({ nodes, ...mockParams }); - - expect(result.nodes).toHaveLength(1); - expect(result.consumed).toBe(1); - - const node = result.nodes[0]; - expect(node.attrs.rsidRDefault).toBe('test123'); - const spacing = node.attrs.paragraphProperties?.spacing; - expect(spacing).toBeDefined(); - expect(spacing.after).toBe(240); // 240 twips = 12px - expect(spacing.before).toBe(120); // 120 twips = 6px - expect(spacing.line).toBe(276); - expect(spacing.lineRule).toBe('auto'); - }); - it('should return empty result for non-v:rect pict elements', () => { const nodes = [ { - name: 'w:p', + name: 'w:pict', elements: [ { - name: 'w:r', - elements: [ - { - name: 'w:pict', - elements: [ - { - name: 'v:shape', - attributes: { style: 'width: 100pt; height: 50pt;' }, - }, - ], - }, - ], + name: 'v:shape', + attributes: { style: 'width: 100pt; height: 50pt;' }, }, ], }, @@ -191,18 +101,8 @@ describe('RectImporter', () => { it('should return empty result for non-pict nodes', () => { const nodes = [ { - name: 'w:p', - elements: [ - { - name: 'w:r', - elements: [ - { - name: 'w:drawing', - attributes: {}, - }, - ], - }, - ], + name: 'w:drawing', + attributes: {}, }, ]; @@ -233,27 +133,11 @@ describe('RectImporter', () => { }; const rect = pict.elements.find((el) => el.name === 'v:rect'); - const pNode = { - elements: [ - { - name: 'w:pPr', - elements: [ - { - name: 'w:spacing', - attributes: { 'w:after': '240' }, - }, - ], - }, - ], - }; - - const result = handleVRectImport({ pict, pNode, params: mockParams }); - - expect(result.type).toBe('paragraph'); - expect(result.content).toHaveLength(1); - expect(result.content[0].type).toBe('contentBlock'); + const result = handleVRectImport({ pict }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('contentBlock'); - const contentBlock = result.content[0]; + const contentBlock = result[0]; expect(contentBlock.attrs.attributes).toEqual(rect.attributes); expect(contentBlock.attrs.style).toBe('width: 200pt;height: 3pt;margin-left: 10pt;'); expect(contentBlock.attrs.size.height).toBe(4); // 3pt * 1.33 ≈ 4px @@ -281,13 +165,12 @@ describe('RectImporter', () => { }, ], }; - const rect = pict.elements.find((el) => el.name === 'v:rect'); - - const pNode = { elements: [] }; - const result = handleVRectImport({ pict, pNode, params: mockParams }); + const result = handleVRectImport({ pict }); + expect(result).toHaveLength(1); - const contentBlock = result.content[0]; + const contentBlock = result[0]; + expect(contentBlock.type).toBe('contentBlock'); expect(contentBlock.attrs.size.width).toBe('100%'); expect(contentBlock.attrs.horizontalRule).toBe(true); }); @@ -305,13 +188,12 @@ describe('RectImporter', () => { }, ], }; - const rect = pict.elements.find((el) => el.name === 'v:rect'); - - const pNode = { elements: [] }; - const result = handleVRectImport({ pict, pNode, params: mockParams }); + const result = handleVRectImport({ pict }); + expect(result).toHaveLength(1); - const contentBlock = result.content[0]; + const contentBlock = result[0]; + expect(contentBlock.type).toBe('contentBlock'); expect(contentBlock.attrs.style).toBeUndefined(); expect(contentBlock.attrs.size).toBeUndefined(); expect(contentBlock.attrs.background).toBe('#CCCCCC'); @@ -331,13 +213,11 @@ describe('RectImporter', () => { }, ], }; - const rect = pict.elements.find((el) => el.name === 'v:rect'); - - const pNode = { elements: [] }; - const result = handleVRectImport({ pict, pNode, params: mockParams }); + const result = handleVRectImport({ pict }); + expect(result).toHaveLength(1); - const contentBlock = result.content[0]; + const contentBlock = result[0]; expect(contentBlock.attrs.attributes.id).toBe('rect1'); expect(contentBlock.attrs.background).toBe('#FF0000'); expect(contentBlock.attrs.horizontalRule).toBeFalsy(); diff --git a/packages/super-editor/src/tests/parity/adapter-parity.test.js b/packages/super-editor/src/tests/parity/adapter-parity.test.js index 3504905a1f..757670ec37 100644 --- a/packages/super-editor/src/tests/parity/adapter-parity.test.js +++ b/packages/super-editor/src/tests/parity/adapter-parity.test.js @@ -3,10 +3,9 @@ import { dirname, join } from 'path'; import { fileURLToPath } from 'node:url'; import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; import { computeParagraphReferenceSnapshot } from '@tests/helpers/paragraphReference.js'; -import { zipFolderToBuffer } from '@tests/helpers/zipFolderToBuffer.js'; -import { Editor } from '@core/Editor.js'; import { computeParagraphAttrs } from '@superdoc/pm-adapter/attributes/paragraph.js'; import { buildConverterContextFromEditor } from '../helpers/adapterTestHelpers.js'; +import { loadUnpackedDocx } from '../helpers/loadUnpackedDocx.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -194,8 +193,7 @@ describe('adapter parity (computeParagraphAttrs)', () => { }); it('computes tab stops when present', async () => { - const buffer = await zipFolderToBuffer(join(__dirname, '../data/tab_stops_basic_test')); - const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); + const [docx, media, mediaFiles, fonts] = await loadUnpackedDocx(join(__dirname, '../data/tab_stops_basic_test')); const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); let paraIndex = -1; diff --git a/packages/super-editor/src/tests/parity/end-to-end-harness.test.js b/packages/super-editor/src/tests/parity/end-to-end-harness.test.js index dd153e72c2..eeb294a269 100644 --- a/packages/super-editor/src/tests/parity/end-to-end-harness.test.js +++ b/packages/super-editor/src/tests/parity/end-to-end-harness.test.js @@ -3,8 +3,7 @@ import { dirname, join } from 'path'; import { fileURLToPath } from 'node:url'; import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; import { computeParagraphReferenceSnapshot } from '@tests/helpers/paragraphReference.js'; -import { zipFolderToBuffer } from '@tests/helpers/zipFolderToBuffer.js'; -import { Editor } from '@core/Editor.js'; +import { loadUnpackedDocx } from '../helpers/loadUnpackedDocx'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -171,8 +170,7 @@ describe('end-to-end parity harness', () => { }); it('compares tab stop metrics across pipeline', async () => { - const buffer = await zipFolderToBuffer(join(__dirname, '../data/tab_stops_basic_test')); - const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); + const [docx, media, mediaFiles, fonts] = await loadUnpackedDocx(join(__dirname, '../data/tab_stops_basic_test')); const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); const paragraphs = extractParagraphData(editor); diff --git a/packages/super-editor/src/tests/parity/paragraph-reference.test.js b/packages/super-editor/src/tests/parity/paragraph-reference.test.js index a16b9a1e00..64d6f08eec 100644 --- a/packages/super-editor/src/tests/parity/paragraph-reference.test.js +++ b/packages/super-editor/src/tests/parity/paragraph-reference.test.js @@ -3,8 +3,7 @@ import { dirname, join } from 'path'; import { fileURLToPath } from 'node:url'; import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; import { computeParagraphReferenceSnapshot } from '@tests/helpers/paragraphReference.js'; -import { zipFolderToBuffer } from '@tests/helpers/zipFolderToBuffer.js'; -import { Editor } from '@core/Editor.js'; +import { loadUnpackedDocx } from '../helpers/loadUnpackedDocx'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -96,8 +95,7 @@ describe('paragraph reference snapshot', () => { }); it('captures tab stop data when present', async () => { - const buffer = await zipFolderToBuffer(join(__dirname, '../data/tab_stops_basic_test')); - const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); + const [docx, media, mediaFiles, fonts] = await loadUnpackedDocx(join(__dirname, '../data/tab_stops_basic_test')); const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); let paraIndex = -1; diff --git a/packages/super-editor/src/tests/parity/tabs-hanging.test.js b/packages/super-editor/src/tests/parity/tabs-hanging.test.js index dbc6d15b54..30853c9cdf 100644 --- a/packages/super-editor/src/tests/parity/tabs-hanging.test.js +++ b/packages/super-editor/src/tests/parity/tabs-hanging.test.js @@ -1,19 +1,17 @@ -import { beforeAll, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { dirname, join } from 'path'; import { fileURLToPath } from 'node:url'; import { initTestEditor } from '@tests/helpers/helpers.js'; import { computeParagraphReferenceSnapshot } from '@tests/helpers/paragraphReference.js'; -import { zipFolderToBuffer } from '@tests/helpers/zipFolderToBuffer.js'; -import { Editor } from '@core/Editor.js'; import { computeParagraphAttrs } from '@superdoc/pm-adapter/attributes/paragraph.js'; import { buildConverterContextFromEditor } from '../helpers/adapterTestHelpers.js'; +import { loadUnpackedDocx } from '../helpers/loadUnpackedDocx.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); describe('tabs and hanging indent parity', () => { it('compares tab stop alignments (left/center/right/decimal)', async () => { - const buffer = await zipFolderToBuffer(join(__dirname, '../data/tab_stops_basic_test')); - const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); + const [docx, media, mediaFiles, fonts] = await loadUnpackedDocx(join(__dirname, '../data/tab_stops_basic_test')); const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); let paraIndex = -1; @@ -63,8 +61,7 @@ describe('tabs and hanging indent parity', () => { }); it('compares default tab interval between reference and adapter', async () => { - const buffer = await zipFolderToBuffer(join(__dirname, '../data/tab_stops_basic_test')); - const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); + const [docx, media, mediaFiles, fonts] = await loadUnpackedDocx(join(__dirname, '../data/tab_stops_basic_test')); const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); let match = null; @@ -91,8 +88,7 @@ describe('tabs and hanging indent parity', () => { }); it('compares hanging indent vs firstLine indent', async () => { - const buffer = await zipFolderToBuffer(join(__dirname, '../data/tab_stops_basic_test')); - const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); + const [docx, media, mediaFiles, fonts] = await loadUnpackedDocx(join(__dirname, '../data/tab_stops_basic_test')); const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); // Find paragraph with hanging or firstLine indent @@ -142,8 +138,7 @@ describe('tabs and hanging indent parity', () => { }); it('ensures tab stop position units are consistent (twips)', async () => { - const buffer = await zipFolderToBuffer(join(__dirname, '../data/tab_stops_basic_test')); - const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); + const [docx, media, mediaFiles, fonts] = await loadUnpackedDocx(join(__dirname, '../data/tab_stops_basic_test')); const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); let paraIndex = -1; @@ -175,8 +170,7 @@ describe('tabs and hanging indent parity', () => { }); it('compares tab leader styles if present', async () => { - const buffer = await zipFolderToBuffer(join(__dirname, '../data/tab_stops_basic_test')); - const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); + const [docx, media, mediaFiles, fonts] = await loadUnpackedDocx(join(__dirname, '../data/tab_stops_basic_test')); const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); let paraIndex = -1; diff --git a/packages/super-editor/tsconfig.build.json b/packages/super-editor/tsconfig.build.json index 129c065694..4b2e467194 100644 --- a/packages/super-editor/tsconfig.build.json +++ b/packages/super-editor/tsconfig.build.json @@ -6,5 +6,5 @@ "noImplicitAny": false, "strict": false }, - "exclude": ["**/*.test.js", "**/*.test.ts", "**/*.spec.js", "**/*.spec.ts"] + "exclude": ["**/*.test.js", "**/*.test.ts", "**/*.spec.js", "**/*.spec.ts", "src/tests/**"] } diff --git a/packages/super-editor/tsconfig.json b/packages/super-editor/tsconfig.json index 34221ed43c..7ff51111f7 100644 --- a/packages/super-editor/tsconfig.json +++ b/packages/super-editor/tsconfig.json @@ -19,6 +19,5 @@ "@translator": ["./src/core/super-converter/v3/node-translator/"] } }, - "include": ["src/**/*"], - "exclude": ["**/*.test.js", "**/*.test.ts"] + "include": ["src/**/*"] }