export const WORDS_DOCUMENT_VERSION = 1; /** * Inline mark — boolean format flags ('bold', 'italic', ...) plus * parametric marks carrying a value via colon-prefixed syntax: * - `color:#ff0000` foreground text color (hex with #) * - `bgcolor:#ffeeaa` highlight / background color * * Parametric marks REPLACE same-prefix entries when toggled (you * can have at most one of each prefix per inline). The HTML * serializer emits them as `style="color:..; background-color:..."`. * Boolean marks toggle as before. */ export type WordsMark = | 'bold' | 'italic' | 'underline' | 'strike' | 'code' | `color:${string}` | `bgcolor:${string}`; export type WordsBlockType = | 'paragraph' | 'heading' | 'quote' | 'code' | 'list' | 'table' | 'image'; export type WordsListKind = 'ordered' | 'unordered' | 'check'; export type WordsHeadingLevel = 1 | 2 | 3; export type WordsTextAlign = 'left' | 'center' | 'right' | 'justify'; // Table types live in the table extension since F2.3 (extension system // migration). Re-exported here so existing consumers that import table // types from `engine/document` keep compiling. New code SHOULD import // from `extensions/table` directly. import type { WordsTableCellVerticalAlign, WordsTableCellTone, WordsTableBlock, WordsTableRow, WordsTableCell, WordsTableOptions, WordsTableCellOptions } from '../extensions/table/types'; export type { WordsTableCellVerticalAlign, WordsTableCellTone, WordsTableBlock, WordsTableRow, WordsTableCell, WordsTableOptions, WordsTableCellOptions }; // Image types live in the image extension (F3). Re-exported here for // the same reason as the table types — keep `engine/document` as the // historical import surface working while new code uses // `extensions/image` directly. import type { WordsImageAlign, WordsImageBlock, WordsImageOptions, WordsImageStatus } from '../extensions/image/types'; export type { WordsImageAlign, WordsImageBlock, WordsImageOptions, WordsImageStatus }; export interface WordsDocument { readonly version: typeof WORDS_DOCUMENT_VERSION; readonly children: readonly WordsBlock[]; } export type WordsBlock = | WordsParagraphBlock | WordsHeadingBlock | WordsQuoteBlock | WordsCodeBlock | WordsListBlock | WordsTableBlock | WordsImageBlock; export interface WordsParagraphBlock { readonly type: 'paragraph'; readonly children: readonly WordsInline[]; readonly textAlign?: WordsTextAlign; } export interface WordsHeadingBlock { readonly type: 'heading'; readonly level: WordsHeadingLevel; readonly children: readonly WordsInline[]; readonly textAlign?: WordsTextAlign; } export interface WordsQuoteBlock { readonly type: 'quote'; readonly children: readonly WordsInline[]; readonly textAlign?: WordsTextAlign; } export interface WordsCodeBlock { readonly type: 'code'; readonly children: readonly WordsInline[]; readonly language?: string; } export interface WordsListBlock { readonly type: 'list'; readonly kind: WordsListKind; readonly children: readonly WordsListItem[]; } export interface WordsListItem { readonly type: 'list-item'; readonly checked?: boolean; readonly indent?: number; readonly children: readonly WordsInline[]; } // Table interface declarations live in `extensions/table/types.ts` since // F2.3. The interfaces (WordsTableBlock, WordsTableRow, WordsTableCell, // WordsTableOptions, WordsTableCellOptions) are re-exported from this // module via the `export type { ... } from '../extensions/table/types'` // statement near the top of the file. export type WordsInline = WordsText | WordsLink; export interface WordsText { readonly type: 'text'; readonly text: string; readonly marks?: readonly WordsMark[]; } export interface WordsLink { readonly type: 'link'; readonly href: string; readonly title?: string; readonly children: readonly WordsInline[]; } export const WORDS_MARKS = [ 'bold', 'italic', 'underline', 'strike', 'code' ] as const satisfies readonly WordsMark[]; export const WORDS_LIST_KINDS = [ 'ordered', 'unordered', 'check' ] as const satisfies readonly WordsListKind[]; export const WORDS_HEADING_LEVELS = [1, 2, 3] as const satisfies readonly WordsHeadingLevel[]; export const WORDS_TEXT_ALIGNS = [ 'left', 'center', 'right', 'justify' ] as const satisfies readonly WordsTextAlign[]; // Table cell value-set constants live in the table extension since // F2.3b. Re-exported here for backward compat with consumers that // import them from `engine/document`. export { WORDS_TABLE_CELL_VERTICAL_ALIGNS, WORDS_TABLE_CELL_TONES } from '../extensions/table/factories'; export function createEmptyWordsDocument(): WordsDocument { return { version: WORDS_DOCUMENT_VERSION, children: [createParagraph()] }; } export function createParagraph( children: readonly WordsInline[] = [createText('')], textAlign?: WordsTextAlign ): WordsParagraphBlock { return { type: 'paragraph', children, ...(textAlign ? { textAlign } : {}) }; } export function createHeading( level: WordsHeadingLevel, children: readonly WordsInline[] = [createText('')], textAlign?: WordsTextAlign ): WordsHeadingBlock { return { type: 'heading', level, children, ...(textAlign ? { textAlign } : {}) }; } export function createQuote( children: readonly WordsInline[] = [createText('')], textAlign?: WordsTextAlign ): WordsQuoteBlock { return { type: 'quote', children, ...(textAlign ? { textAlign } : {}) }; } export function createCodeBlock( children: readonly WordsInline[] = [createText('')], language?: string ): WordsCodeBlock { const normalizedLanguage = normalizeWordsCodeLanguage(language); return { type: 'code', children, ...(normalizedLanguage ? { language: normalizedLanguage } : {}) }; } export function createList( kind: WordsListKind, children: readonly WordsListItem[] = [createListItem()] ): WordsListBlock { return { type: 'list', kind, children }; } export function createListItem( children: readonly WordsInline[] = [createText('')], checked = false, indent?: number ): WordsListItem { return { type: 'list-item', ...(checked ? { checked } : {}), ...(indent ? { indent } : {}), children }; } // Table factory functions live in the table extension since F2.3b. // Re-exported here for backward compat — see `extensions/table/factories.ts`. export { createTable, createTableRow, createTableCell } from '../extensions/table/factories'; export function createText(text: string, marks: readonly WordsMark[] = []): WordsText { const normalizedMarks = normalizeMarks(marks); return { type: 'text', text, ...(normalizedMarks.length ? { marks: normalizedMarks } : {}) }; } export function createLink( href: string, children: readonly WordsInline[], title?: string ): WordsLink { return { type: 'link', href, ...(title ? { title } : {}), children }; } export function normalizeMarks(marks: readonly WordsMark[] | undefined): readonly WordsMark[] { if (!marks?.length) return []; const order = new Map( (WORDS_MARKS as readonly string[]).map((mark, index) => [mark, index]) ); // Dedupe boolean marks; for parametric marks (color:X, bgcolor:Y) // keep only the LAST value per prefix so multiple toggles in a // row resolve to the user's final choice. const seenPrefix = new Map(); const booleanSet = new Set(); for (const mark of marks) { const colon = mark.indexOf(':'); if (colon > 0) { seenPrefix.set(mark.slice(0, colon), mark); } else { booleanSet.add(mark); } } const dedup = [...booleanSet, ...seenPrefix.values()]; // Sort: boolean marks first in declaration order, parametric marks // after (stable). This keeps existing tests happy. return dedup.sort((a, b) => { const ai = order.get(a) ?? 1e6; const bi = order.get(b) ?? 1e6; return ai - bi; }); } export function sameMarks( a: readonly WordsMark[] | undefined, b: readonly WordsMark[] | undefined ): boolean { const left = normalizeMarks(a); const right = normalizeMarks(b); if (left.length !== right.length) return false; return left.every((mark, index) => mark === right[index]); } export function isWordsMark(value: unknown): value is WordsMark { if (typeof value !== 'string') return false; if ((WORDS_MARKS as readonly string[]).includes(value)) return true; // Parametric: `color:#hex` or `bgcolor:#hex`. Liberal hex check — // anything that's `#` + 3/4/6/8 hex digits. const colorMatch = value.match(/^(color|bgcolor):(#[0-9a-fA-F]{3,8})$/); return !!colorMatch; } /** * Read the parametric value carried by a mark, or undefined for * boolean marks. Returns the part after the colon. * * markValue('color:#ff0000') // '#ff0000' * markValue('bold') // undefined */ export function markValue(mark: WordsMark): string | undefined { const colon = mark.indexOf(':'); return colon > 0 ? mark.slice(colon + 1) : undefined; } /** Prefix of a parametric mark, or the mark itself for boolean ones. */ export function markPrefix(mark: WordsMark): string { const colon = mark.indexOf(':'); return colon > 0 ? mark.slice(0, colon) : mark; } export function isWordsHeadingLevel(value: unknown): value is WordsHeadingLevel { return typeof value === 'number' && (WORDS_HEADING_LEVELS as readonly number[]).includes(value); } export function isWordsListKind(value: unknown): value is WordsListKind { return typeof value === 'string' && (WORDS_LIST_KINDS as readonly string[]).includes(value); } export function isWordsTextAlign(value: unknown): value is WordsTextAlign { return typeof value === 'string' && (WORDS_TEXT_ALIGNS as readonly string[]).includes(value); } // Table predicates live in the table extension since F2.3b. // Re-exported here for backward compat. export { isWordsTableCellVerticalAlign, isWordsTableCellTone } from '../extensions/table/factories'; export function normalizeWordsCodeLanguage(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; const language = value.trim().toLowerCase(); return /^[a-z0-9][a-z0-9+_.-]{0,31}$/.test(language) ? language : undefined; } export function isTextInline(inline: WordsInline): inline is WordsText { return inline.type === 'text'; } export function isLinkInline(inline: WordsInline): inline is WordsLink { return inline.type === 'link'; } export function getBlockText(block: WordsBlock): string { if (block.type === 'list') { return block.children.map((item) => item.children.map(getInlineText).join('')).join('\n'); } if (block.type === 'table') { return block.children .map((row) => row.children.map((cell) => cell.children.map(getInlineText).join('')).join('\t') ) .join('\n'); } if (block.type === 'image') { // Plain-text representation of an image is its alt text. Used // for word count, copy-as-plaintext and accessibility flows. // Captions are *not* included to keep the image a single // semantic atom in plain-text contexts. return block.alt ?? ''; } return block.children.map(getInlineText).join(''); } export function getInlineText(inline: WordsInline): string { return getInlineTextSafe(inline, new WeakSet()); } function getInlineTextSafe(inline: WordsInline, seen: WeakSet): string { if (seen.has(inline)) return ''; seen.add(inline); if (inline.type === 'text') return inline.text; return inline.children.map((child) => getInlineTextSafe(child, seen)).join(''); }