From b61fadca487eaf84c8d77127220e77a08c7b881d Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 8 Aug 2026 10:06:31 +0000 Subject: [PATCH] feat: the character effects Word and LO carry beyond bold and italic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Letter case, the underline's shape and colour, a double strikethrough and a freely raised run all had no home in the model, so every one of them was dropped on import. They ride the marks now, render in CSS, and round-trip. odf-kit's run formatting has a field for none of them, so a sentinel folds them into the run's own automatic style — the route the character styles already take, and it reaches every path odf-kit serializes. --- src/lib/editor/extensions.ts | 9 +- src/lib/editor/extensions/headerFooter.ts | 9 +- src/lib/editor/extensions/textEffects.ts | 103 +++++++++++++++ src/lib/export/docx.ts | 39 +++++- src/lib/export/odt.ts | 152 ++++++++++++++++++++-- src/lib/import/docx.ts | 35 ++++- src/lib/import/docxStyles.ts | 34 ++++- src/lib/import/odt.ts | 54 +++++++- src/lib/styles/styleSheet.ts | 3 + tests/docx-roundtrip.test.ts | 19 +++ tests/roundtrip.test.ts | 12 +- 11 files changed, 436 insertions(+), 33 deletions(-) create mode 100644 src/lib/editor/extensions/textEffects.ts diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index d514dca..69f88b3 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -3,8 +3,7 @@ import Paragraph from '@tiptap/extension-paragraph'; import Text from '@tiptap/extension-text'; import Bold from '@tiptap/extension-bold'; import Italic from '@tiptap/extension-italic'; -import Underline from '@tiptap/extension-underline'; -import Strike from '@tiptap/extension-strike'; +import { TextEffects, UnderlineStyled, StrikeStyled } from './extensions/textEffects'; import Subscript from '@tiptap/extension-subscript'; import Superscript from '@tiptap/extension-superscript'; import Highlight from '@tiptap/extension-highlight'; @@ -62,8 +61,8 @@ export const extensions = [ Text, Bold, Italic, - Underline, - Strike, + UnderlineStyled, + StrikeStyled, Link, Subscript, Superscript, @@ -72,6 +71,8 @@ export const extensions = [ FontSize, FontWeight, FontColor, + // Letter case, raised/lowered runs and the line styles of underline/strikethrough. + TextEffects, // multicolor stores the chosen color on the `highlight` mark's `color` attr; // odf-kit exports that natively to fo:background-color. See export/odt.ts // applyRuns for the custom-attr-paragraph export path. diff --git a/src/lib/editor/extensions/headerFooter.ts b/src/lib/editor/extensions/headerFooter.ts index 81db872..e1ed33c 100644 --- a/src/lib/editor/extensions/headerFooter.ts +++ b/src/lib/editor/extensions/headerFooter.ts @@ -3,8 +3,7 @@ import Paragraph from '@tiptap/extension-paragraph'; import Text from '@tiptap/extension-text'; import Bold from '@tiptap/extension-bold'; import Italic from '@tiptap/extension-italic'; -import Underline from '@tiptap/extension-underline'; -import Strike from '@tiptap/extension-strike'; +import { TextEffects, UnderlineStyled, StrikeStyled } from './textEffects'; import Subscript from '@tiptap/extension-subscript'; import Superscript from '@tiptap/extension-superscript'; import Highlight from '@tiptap/extension-highlight'; @@ -46,8 +45,8 @@ export function hfExtensions(placeholder = '') { Text, Bold, Italic, - Underline, - Strike, + UnderlineStyled, + StrikeStyled, Subscript, Superscript, TextStyle, @@ -55,6 +54,8 @@ export function hfExtensions(placeholder = '') { FontSize, FontWeight, FontColor, + // Letter case, raised/lowered runs and the line styles of underline/strikethrough. + TextEffects, // Paragraph background ("colored field") + rule line, common in letterhead headers. ParagraphBox, CharacterStyle, diff --git a/src/lib/editor/extensions/textEffects.ts b/src/lib/editor/extensions/textEffects.ts new file mode 100644 index 0000000..833b21f --- /dev/null +++ b/src/lib/editor/extensions/textEffects.ts @@ -0,0 +1,103 @@ +import Underline from '@tiptap/extension-underline'; +import Strike from '@tiptap/extension-strike'; +import { Extension } from '@tiptap/core'; +import '@tiptap/extension-text-style'; + +// The character effects beyond bold/italic that both Word and LibreOffice carry and +// CSS can draw: letter case, a raised or lowered run, and the line styles of the +// underline and strikethrough marks. + +export type CapsMode = 'uppercase' | 'lowercase' | 'capitalize' | 'smallCaps'; +// ODF style:text-underline-style / Word w:u w:val, in the CSS spelling. +export type LineStyle = 'solid' | 'double' | 'dotted' | 'dashed' | 'wavy'; + +const CAPS_CSS: Record = { + uppercase: 'text-transform: uppercase', + lowercase: 'text-transform: lowercase', + capitalize: 'text-transform: capitalize', + smallCaps: 'font-variant-caps: small-caps', +}; + +const isCaps = (v: unknown): v is CapsMode => typeof v === 'string' && v in CAPS_CSS; +const isLineStyle = (v: unknown): v is LineStyle => + v === 'solid' || v === 'double' || v === 'dotted' || v === 'dashed' || v === 'wavy'; + +function capsFromStyle(el: HTMLElement): CapsMode | null { + if (el.style.fontVariantCaps === 'small-caps' || el.style.fontVariant === 'small-caps') return 'smallCaps'; + const t = el.style.textTransform; + return isCaps(t) ? t : null; +} + +// Letter case and vertical offset ride the TextStyle mark, like colour and size: +// they are properties of the run, not lines drawn over it. +export const TextEffects = Extension.create({ + name: 'textEffects', + + addOptions() { + return { types: ['textStyle'] }; + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + caps: { + default: null, + parseHTML: element => capsFromStyle(element as HTMLElement), + renderHTML: attributes => + isCaps(attributes.caps) ? { style: CAPS_CSS[attributes.caps] } : {}, + }, + // pt above the baseline (negative = below), independent of sub/superscript. + textPosition: { + default: null, + parseHTML: element => { + const v = parseFloat((element as HTMLElement).style.verticalAlign); + return Number.isFinite(v) && v !== 0 ? v : null; + }, + renderHTML: attributes => + typeof attributes.textPosition === 'number' && attributes.textPosition + ? { style: `vertical-align: ${attributes.textPosition}pt` } + : {}, + }, + }, + }, + ]; + }, +}); + +// The element draws the line, so its style and colour belong on that mark. +export const UnderlineStyled = Underline.extend({ + addAttributes() { + return { + lineStyle: { + default: null, + parseHTML: el => { + const s = (el as HTMLElement).style.textDecorationStyle; + return isLineStyle(s) && s !== 'solid' ? s : null; + }, + renderHTML: attrs => (isLineStyle(attrs.lineStyle) ? { style: `text-decoration-style: ${attrs.lineStyle}` } : {}), + }, + lineColor: { + default: null, + parseHTML: el => (el as HTMLElement).style.textDecorationColor || null, + renderHTML: attrs => (attrs.lineColor ? { style: `text-decoration-color: ${attrs.lineColor}` } : {}), + }, + }; + }, +}); + +export const StrikeStyled = Strike.extend({ + addAttributes() { + return { + lineStyle: { + default: null, + parseHTML: el => { + const s = (el as HTMLElement).style.textDecorationStyle; + return isLineStyle(s) && s !== 'solid' ? s : null; + }, + renderHTML: attrs => (isLineStyle(attrs.lineStyle) ? { style: `text-decoration-style: ${attrs.lineStyle}` } : {}), + }, + }; + }, +}); diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 7820a17..7de9c1f 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -178,6 +178,17 @@ function markPresent(marks: TiptapNode['marks'], type: string): boolean { return !!marks?.some((m) => m.type === type); } +// The underline mark's CSS line style → Word's name for the same shape, plus its colour. +function wordUnderline(attrs: Record | undefined): { type: (typeof UnderlineType)[keyof typeof UnderlineType]; color?: string } { + const byStyle: Record = { + double: UnderlineType.DOUBLE, dotted: UnderlineType.DOTTED, + dashed: UnderlineType.DASH, wavy: UnderlineType.WAVE, + }; + const type = byStyle[String(attrs?.lineStyle ?? '')] ?? UnderlineType.SINGLE; + const color = attrs?.lineColor ? hexColor(String(attrs.lineColor)) : undefined; + return color ? { type, color } : { type }; +} + // TipTap marks + textStyle attrs → Word run properties. `force` bakes a cell's // presentational formatting (header row, table-style region) onto every run, // respecting an explicit fontWeight:normal un-bold. @@ -200,11 +211,19 @@ function runPropsFromMarks(marks: TiptapNode['marks'] = [], force: TextProps = { if (bold !== undefined) props.bold = bold; if (markPresent(marks, 'italic') || force.italic) props.italics = true; - if (markPresent(marks, 'underline')) props.underline = { type: UnderlineType.SINGLE }; - if (markPresent(marks, 'strike')) props.strike = true; + const u = marks.find((m) => m.type === 'underline'); + if (u) props.underline = wordUnderline(u.attrs); + const st = marks.find((m) => m.type === 'strike'); + if (st) { if (st.attrs?.lineStyle === 'double') props.doubleStrike = true; else props.strike = true; } if (markPresent(marks, 'superscript')) props.superScript = true; else if (markPresent(marks, 'subscript')) props.subScript = true; + const caps = ts?.attrs?.caps; + if (caps === 'smallCaps') props.smallCaps = true; + else if (caps === 'uppercase') props.allCaps = true; + const pos = ts?.attrs?.textPosition; + if (typeof pos === 'number' && pos) props.position = `${pos}pt`; + const ff = ts?.attrs?.fontFamily ?? force.fontFamily; if (ff) props.font = String(ff) === SCREEN_FONT ? DOC_FONT : String(ff); const fs = ts?.attrs?.fontSize ?? (force.fontSizePt != null ? `${force.fontSizePt}pt` : null); @@ -418,7 +437,13 @@ function txbxRunPropsXml(marks: TiptapNode['marks'] = []): string { } if (bold) parts.push(''); if (markPresent(marks, 'italic')) parts.push(''); - if (markPresent(marks, 'strike')) parts.push(''); + const st = marks.find((m) => m.type === 'strike'); + if (st) parts.push(st.attrs?.lineStyle === 'double' ? '' : ''); + const caps = ts?.attrs?.caps; + if (caps === 'smallCaps') parts.push(''); + else if (caps === 'uppercase') parts.push(''); + const pos = ts?.attrs?.textPosition; + if (typeof pos === 'number' && pos) parts.push(``); const col = ts?.attrs?.color; if (col) { const h = hexColor(String(col)); @@ -429,7 +454,11 @@ function txbxRunPropsXml(marks: TiptapNode['marks'] = []): string { const hp = fontSizeToHalfPoints(String(fs)); if (hp) parts.push(``); } - if (markPresent(marks, 'underline')) parts.push(''); + const u = marks.find((m) => m.type === 'underline'); + if (u) { + const line = wordUnderline(u.attrs); + parts.push(``); + } const hl = marks.find((m) => m.type === 'highlight'); if (hl?.attrs?.color) { const h = hexColor(String(hl.attrs.color)); @@ -953,6 +982,8 @@ function paragraphStyleOf(style: Style): IParagraphStyleOptions { if (t.underline) run.underline = {}; if (t.strike) run.strike = t.strike; if (t.color) run.color = t.color.replace('#', ''); + if (t.caps === 'smallCaps') run.smallCaps = true; + else if (t.caps === 'uppercase') run.allCaps = true; const spacing: Record = {}; if (p.spaceBefore != null) spacing.before = ptToTwip(p.spaceBefore); if (p.spaceAfter != null) spacing.after = ptToTwip(p.spaceAfter); diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 5e33167..5da1211 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -52,6 +52,8 @@ const ODFKIT_DEFAULT_FONT = 'Liberation Serif'; // Liberation Serif, so LibreOffice (substitutes TNR→Liberation Serif) and Word // (has the real TNR) both render with the same metrics as the editor. const EXPORT_FONT = 'Times New Roman'; +// The body size a run without one of its own renders at (LibreOffice's default). +const DEFAULT_FONT_SIZE_PT = 12; const DEFAULT_LINE_HEIGHT = 1; // must match line-height multiplier default in ToolbarExpanded.svelte // Sentinel between a cell's blocks (and list items) so the single odf-kit @@ -126,6 +128,11 @@ const STY = ''; // applyCharacterStyles points the run's span at that style. U+E00E. const CST = '\uE00E'; +// Sentinel prefixed to a run carrying character effects odf-kit's run formatting has no +// field for (TEFTEF); applyTextEffects folds them into the run's own +// automatic text style. U+E00F. +const TEF = '\uE00F'; + const EXT_BY_MIME: Record = { 'image/png': 'png', 'image/jpeg': 'jpg', @@ -1164,6 +1171,8 @@ function ownStyleAttrs(style: { para: Record; text: Record = { dotted: 'dotted', dashed: 'dash', wavy: 'wave' }; + +// The effects ODF carries but odf-kit's run formatting has no field for. Returned as +// the attributes applyTextEffects folds into the run's style. +export function odfExtraTextProps(marks: TiptapNode['marks'] = []): string { + const a: string[] = []; + const caps = marks.find(m => m.type === 'textStyle')?.attrs?.caps; + if (caps === 'smallCaps') a.push('fo:font-variant="small-caps"'); + else if (caps === 'uppercase' || caps === 'lowercase' || caps === 'capitalize') a.push(`fo:text-transform="${caps}"`); + const u = marks.find(m => m.type === 'underline')?.attrs; + if (u?.lineStyle === 'double') a.push('style:text-underline-type="double"'); + else if (typeof u?.lineStyle === 'string' && ODF_LINE_STYLE[u.lineStyle]) { + a.push(`style:text-underline-style="${ODF_LINE_STYLE[u.lineStyle]}"`); + } + if (u?.lineColor) { + const c = normalizeColor(String(u.lineColor)); + if (c) a.push(`style:text-underline-color="${c}"`); + } + if (marks.find(m => m.type === 'strike')?.attrs?.lineStyle === 'double') { + a.push('style:text-line-through-type="double"'); + } + // ODF places a raised run in percent of its font size, Word and the editor in pt. + const pos = marks.find(m => m.type === 'textStyle')?.attrs?.textPosition; + if (typeof pos === 'number' && pos) { + a.push(`style:text-position="${Math.round((pos / runSizePt(marks)) * 10000) / 100}% 100%"`); + } + return a.join(' '); +} + +// The run's own size where it declares one — the reference for the percentage above. +function runSizePt(marks: TiptapNode['marks']): number { + const size = marks?.find(m => m.type === 'textStyle')?.attrs?.fontSize; + const pt = size ? parseFloat(String(size)) : NaN; + return Number.isFinite(pt) && pt > 0 ? pt : DEFAULT_FONT_SIZE_PT; +} + function charStyleOf(marks: TiptapNode['marks']): string | null { const name = marks?.find(m => m.type === 'charStyle')?.attrs?.name; return typeof name === 'string' && name ? name : null; @@ -2134,6 +2180,17 @@ function charFormatting(sheet: StyleSheet, name: string): Record void, prefix: string): string { + if (!xml.includes(TEF)) return xml; + const nameByKey = new Map(); + const styleFor = (source: string, extra: string): string => { + const key = `${source}|${extra}`; + const existing = nameByKey.get(key); + if (existing) return existing; + const name = `${prefix}${nameByKey.size + 1}`; + nameByKey.set(key, name); + const auto = source ? findAutoStyle(xml, source) : null; + const names = [...extra.matchAll(/([\w:.-]+)=/g)].map(m => m[1]); + mint(auto + ? auto.replace(/style:name="[^"]*"/, `style:name="${name}"`) + .replace(/]*?)(\/?)>/, (_m, attrs: string, slash: string) => { + const kept = names.reduce((acc, n) => acc.replace(new RegExp(`\\s${n}="[^"]*"`, 'g'), ''), attrs); + return ``; + }) + : ``); + return name; + }; + + const tefRe = new RegExp(`${TEF}([^${TEF}]*)${TEF}`); + let out = xml.replace( + new RegExp(`]*)>([\\s\\S]*?)`, 'g'), + (m, attrs: string, inner: string) => { + const sm = tefRe.exec(inner); + if (!sm) return m; + const srcM = /text:style-name="([^"]*)"/.exec(attrs); + const name = styleFor(srcM ? srcM[1] : '', sm[1]); + const newAttrs = srcM + ? attrs.replace(/text:style-name="[^"]*"/, `text:style-name="${name}"`) + : ` text:style-name="${name}"${attrs}`; + return `${inner.replace(new RegExp(tefRe.source, 'g'), '')}`; + }, + ); + // A run whose only formatting is such an effect never got a span: give it one, up to + // the next element (a tab or line break carries no visible effect anyway). + out = out.replace( + new RegExp(`${TEF}([^${TEF}]*)${TEF}([^<${TEF}]*)`, 'g'), + (_m, extra: string, text: string) => `${text}`, + ); + return out; +} + +function applyTextEffects(odtBytes: Uint8Array): Uint8Array { + const files = unzipSync(odtBytes); + const contentBytes = files['content.xml']; + if (!contentBytes) return odtBytes; + + const before = strFromU8(contentBytes); + const minted: string[] = []; + const content = resolveTextEffects(before, (s) => minted.push(s), 'TE'); + if (content === before) return odtBytes; + + files['content.xml'] = strToU8( + minted.length ? content.replace('', `${minted.join('')}`) : content, + ); + return rezipOdt(files); +} + // Emit the header/footer paragraph into odf-kit's HeaderFooterBuilder. hardBreak // and pageCount ride as sentinels (LBR / PGC-wrapped digits) and are rewritten to // / in applyHfPostProcess (styles.xml). @@ -2855,7 +2975,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU const columns: ColumnsExport[] = []; const dateFields: DateTimeFieldExport[] = []; const sentinels = replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns))), images), dateFields); - const raw = bakeListCharStyles(sentinels, styles); + const raw = markTextEffects(bakeListCharStyles(sentinels, styles)); let headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null; let footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null; // Different first page (ODF header-first): page 1 gets its own zone content. @@ -2869,6 +2989,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU // Hoist header/footer inline images out to HFIMG sentinels before odf-kit serializes // the zones; applyHfPostProcess rewrites them to in styles.xml. const hfImages: ImageExport[] = []; + headerPara = headerPara && markTextEffects(headerPara); + footerPara = footerPara && markTextEffects(footerPara); headerPara = replaceHfImages(headerPara, hfImages); footerPara = replaceHfImages(footerPara, hfImages); firstHeaderPara = replaceHfImages(firstHeaderPara, hfImages); @@ -3055,7 +3177,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU const withPageBreaks = applyPageBreaks(withToc); const withEmptyFontSizes = applyEmptyLineFontSizes(withPageBreaks); const withParaBoxes = applyParagraphBoxes(withEmptyFontSizes); - const withNamedStyles = applyCharacterStyles(applyParagraphStyles(withParaBoxes)); + // Effects first: applyCharacterStyles then clones the style that already carries them. + const withNamedStyles = applyCharacterStyles(applyTextEffects(applyParagraphStyles(withParaBoxes))); const usedTables = new Set(tableStyleNames.filter((t): t is TableStyleRef => !!t).map(t => t.name)); const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables); return applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages, evenHeaderPara, evenFooterPara); @@ -3099,6 +3222,10 @@ function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPa styles = styles.split(LBR).join(''); styles = styles.replace(new RegExp(`${PGC}(\\d*)${PGC}`, 'g'), '$1'); + const mintedStyles: string[] = []; + const mint = (n: string) => { mintedStyles.push(n); }; + styles = resolveTextEffects(styles, mint, 'HFTE'); + const round3 = (v: number) => Math.round(v * 1000) / 1000; const zone = (kind: 'header' | 'footer', para: TiptapNode, bodyMarginCm: number, distCm: number) => { // min-height fills the gap between the edge→zone distance and the body margin, @@ -3126,8 +3253,6 @@ function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPa // into the master page (ODF 1.3; LibreOffice reads them). The page-layout header/footer // geometry above is shared across variants, so only the content differs here. if (firstHeaderPara || firstFooterPara || evenHeaderPara || evenFooterPara) { - const mintedStyles: string[] = []; - const mint = (n: string) => { mintedStyles.push(n); }; const injectVariant = (kind: 'header' | 'footer', suffix: 'first' | 'left', para: TiptapNode | null) => { if (!para) return; const xml = hfVariantZoneXml(kind, suffix, para, pageCount, mint); @@ -3142,11 +3267,11 @@ function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPa injectVariant('header', 'left', evenHeaderPara); injectVariant('footer', 'first', firstFooterPara); injectVariant('footer', 'left', evenFooterPara); - if (mintedStyles.length) { - const defs = mintedStyles.join(''); - if (styles.includes('')) styles = styles.replace('', `${defs}`); - else styles = styles.replace('', `${defs}`); - } + } + if (mintedStyles.length) { + const defs = mintedStyles.join(''); + if (styles.includes('')) styles = styles.replace('', `${defs}`); + else styles = styles.replace('', `${defs}`); } // Resolve HFIMG sentinels (default + first-page zones) to as-char s, then @@ -3222,11 +3347,18 @@ function hfVariantZoneXml(kind: 'header' | 'footer', suffix: 'first' | 'left', p function odfTextPropsFromMarks(marks: TiptapNode['marks']): string | null { const fmt = formattingFromMarks(marks); const a: string[] = []; + // The effects below name the underline's own shape and colour where the run has them. + const extra = odfExtraTextProps(marks); if (fmt.fontWeight != null) a.push(`fo:font-weight="${fmt.fontWeight}"`); else if (fmt.bold) a.push('fo:font-weight="bold"'); if (fmt.italic) a.push('fo:font-style="italic"'); - if (fmt.underline) a.push('style:text-underline-style="solid" style:text-underline-width="auto" style:text-underline-color="font-color"'); + if (fmt.underline) { + a.push('style:text-underline-width="auto"'); + if (!/style:text-underline-style=/.test(extra)) a.push('style:text-underline-style="solid"'); + if (!/style:text-underline-color=/.test(extra)) a.push('style:text-underline-color="font-color"'); + } if (fmt.strikethrough) a.push('style:text-line-through-style="solid"'); + if (extra) a.push(extra); if (fmt.superscript) a.push('style:text-position="super 58%"'); else if (fmt.subscript) a.push('style:text-position="sub 58%"'); if (fmt.fontFamily) { diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 50e3a31..b501ed3 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -6,6 +6,7 @@ import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type Styl import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow'; import { fitInlineImage } from '../editor/extensions/image'; import { formatTabStops } from '../editor/extensions/tabStops'; +import type { CapsMode, LineStyle } from '../editor/extensions/textEffects'; import { tableLookAttr } from '../styles/tableStyles'; import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes'; import { bulletCharAttr, bulletCharFromDocx } from '../utils/bulletListTypes'; @@ -534,6 +535,7 @@ type BlockDefaults = { italic: boolean; underline: boolean; strike: boolean; + caps: CapsMode | null; }; const FONT_TWINS: Record = { @@ -561,6 +563,7 @@ function blockDefaults(baseRun: RunProps, headingLevel: number | null, boldByDef italic: !!baseRun.italic, underline: !!baseRun.underline, strike: !!baseRun.strike, + caps: baseRun.caps || null, }; } @@ -637,6 +640,7 @@ function runTextProps(run: RunProps): TextProps { if (run.strike != null) out.strike = run.strike; const color = hexColor(run.color); if (color) out.color = color; + if (run.caps) out.caps = run.caps; return out; } @@ -1083,8 +1087,13 @@ function marksFor(props: RunProps, defaults: BlockDefaults, inLink: boolean): Ma // Marks the block's named style already renders need no mark of their own. if (props.italic && !defaults.italic) marks.push({ type: 'italic' }); - if (props.underline && !inLink && !defaults.underline) marks.push({ type: 'underline' }); // link underline is the CSS default - if (props.strike && !defaults.strike) marks.push({ type: 'strike' }); + if (props.underline && !inLink && !defaults.underline) { // link underline is the CSS default + const line = underlineAttrs(props); + marks.push(line ? { type: 'underline', attrs: line } : { type: 'underline' }); + } + if (props.strike && !defaults.strike) { + marks.push({ type: 'strike', ...(props.doubleStrike ? { attrs: { lineStyle: 'double' } } : {}) }); + } if (props.vertAlign === 'superscript') marks.push({ type: 'superscript' }); else if (props.vertAlign === 'subscript') marks.push({ type: 'subscript' }); @@ -1099,10 +1108,32 @@ function marksFor(props: RunProps, defaults: BlockDefaults, inLink: boolean): Ma if (props.font && !defaults.fonts.has(props.font.toLowerCase())) textStyle.fontFamily = props.font; + if (props.caps && props.caps !== defaults.caps) textStyle.caps = props.caps; + if (props.positionPt) textStyle.textPosition = props.positionPt; + if (Object.keys(textStyle).length) marks.push({ type: 'textStyle', attrs: textStyle }); return marks; } +// Word names a dozen underline styles; CSS draws four shapes, so each name maps to the +// one it looks like. 'single' is the default and needs no attr. +const UNDERLINE_STYLE: Record = { + double: 'double', wavyDouble: 'double', + dotted: 'dotted', dottedHeavy: 'dotted', dotDash: 'dotted', dotDotDash: 'dotted', + dash: 'dashed', dashedHeavy: 'dashed', dashLong: 'dashed', dashLongHeavy: 'dashed', + dashDotHeavy: 'dashed', dashDotDotHeavy: 'dashed', + wave: 'wavy', wavyHeavy: 'wavy', +}; + +function underlineAttrs(props: RunProps): Record | undefined { + const attrs: Record = {}; + const style = props.underlineVal ? UNDERLINE_STYLE[props.underlineVal] : undefined; + if (style) attrs.lineStyle = style; + const color = hexColor(props.underlineColor); + if (color) attrs.lineColor = color; + return Object.keys(attrs).length ? attrs : undefined; +} + // ---- images ----------------------------------------------------------------- function loadImageDataUrl(path: string, ctx: Ctx): string | null { const cached = ctx.imageCache.get(path); diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts index 8838caa..a4b8e1e 100644 --- a/src/lib/import/docxStyles.ts +++ b/src/lib/import/docxStyles.ts @@ -1,4 +1,6 @@ import type { TabAlign, TabStop } from '../editor/extensions/tabStops'; +import type { CapsMode } from '../editor/extensions/textEffects'; +import { lengthToPt } from './styleResolver'; // Resolves OOXML style indirection for the DOCX importer: Word/LibreOffice spread run // formatting across w:docDefaults and named styles linked by w:basedOn, and store list @@ -28,6 +30,11 @@ export type RunProps = { font?: string; // explicit w:rFonts w:ascii/hAnsi fontTheme?: 'minor' | 'major'; // w:rFonts w:asciiTheme/hAnsiTheme → theme1.xml font highlightFill?: string; // text highlight: w:shd w:fill, or w:highlight's palette colour + caps?: CapsMode | false; // w:caps / w:smallCaps; false = a run switching the style's off + underlineVal?: string; // w:u w:val, the line's style ('single' | 'double' | 'wave' | …) + underlineColor?: string; // w:u w:color (raw hex) + doubleStrike?: boolean; // w:dstrike + positionPt?: number; // w:position: pt above the baseline (negative = below) }; // A numbering level definition (numbering.xml w:lvl). bulletFont is the level's @@ -65,6 +72,21 @@ export function toggle(el: Element): boolean { return v == null || !(v === 'false' || v === '0' || v === 'off'); } +// ST_SignedHpsMeasure: a bare number counts half-points, anything else carries its unit. +function signedHalfPointsPt(v: string | null): number | null { + if (!v) return null; + if (/^-?[\d.]+$/.test(v)) { const n = parseFloat(v); return Number.isFinite(n) ? n / 2 : null; } + return lengthToPt(v) ?? null; +} + +// Word writes the two case toggles separately; small caps wins where both are on. +function readCaps(p: RunProps, child: Element): void { + const on = toggle(child); + if (child.localName === 'smallCaps') p.caps = on ? 'smallCaps' : false; + else if (on) { if (p.caps !== 'smallCaps') p.caps = 'uppercase'; } + else if (p.caps === 'uppercase') p.caps = false; +} + // The highlighter pen's fixed palette (w:highlight names a colour where w:shd gives a // hex). 'none' maps to the same sentinel as an empty w:shd, so it cancels an inherited pen. const HIGHLIGHT_HEX: Record = { @@ -84,7 +106,17 @@ export function parseRunProps(rPr: Element | null | undefined): RunProps { case 'b': p.bold = toggle(child); break; case 'i': p.italic = toggle(child); break; case 'strike': p.strike = toggle(child); break; - case 'u': p.underline = wVal(child) !== 'none'; break; + case 'u': { + const v = wVal(child); + p.underline = v !== 'none'; + if (v) p.underlineVal = v; + const c = child.getAttributeNS(W, 'color'); + if (c && c !== 'auto') p.underlineColor = c; + break; + } + case 'dstrike': { const on = toggle(child); p.doubleStrike = on; if (on) p.strike = true; break; } + case 'caps': case 'smallCaps': readCaps(p, child); break; + case 'position': { const pt = signedHalfPointsPt(wVal(child)); if (pt != null) p.positionPt = pt; break; } case 'vertAlign': { const v = wVal(child); if (v === 'superscript' || v === 'subscript') p.vertAlign = v; diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 672c135..80bf4ee 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -5,6 +5,7 @@ import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type Styl import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow'; import { fitInlineImage } from '../editor/extensions/image'; import { formatTabStops } from '../editor/extensions/tabStops'; +import type { CapsMode, LineStyle } from '../editor/extensions/textEffects'; import { TABLE_REGIONS, tableLookAttr, type TableLook, type TableRegion } from '../styles/tableStyles'; import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes'; import { bulletCharAttr, bulletCharFromOdf } from '../utils/bulletListTypes'; @@ -691,6 +692,7 @@ type BlockDefaults = { italic: boolean; underline: boolean; strike: boolean; + caps: CapsMode | null; }; // Metric twins: the on-screen font and the name we declare in files mean the same thing. @@ -714,6 +716,7 @@ function blockDefaults(resolver: StyleResolver, named: string | null, headingLev italic: false, underline: false, strike: false, + caps: null, }; if (!named) return fallback; const para = resolver.paraProps(named); @@ -737,6 +740,7 @@ function blockDefaults(resolver: StyleResolver, named: string | null, headingLev italic: fontStyle === 'italic' || fontStyle === 'oblique', underline: !!text['style:text-underline-style'] && text['style:text-underline-style'] !== 'none', strike: !!text['style:text-line-through-style'] && text['style:text-line-through-style'] !== 'none', + caps: capsFromOdf(text), }; } @@ -799,6 +803,8 @@ function textPropsFromOdf(props: PropMap, resolver: StyleResolver): TextProps { if (lt) out.strike = lt !== 'none'; const color = props['fo:color'] ? normalizeColor(props['fo:color']) : undefined; if (color) out.color = color; + const caps = capsFromOdf(props); + if (caps) out.caps = caps; return out; } @@ -877,6 +883,7 @@ function charDefaults(ctx: Ctx, base: BlockDefaults, odfName: string): BlockDefa italic: fontStyle ? fontStyle === 'italic' || fontStyle === 'oblique' : base.italic, underline: props['style:text-underline-style'] ? props['style:text-underline-style'] !== 'none' : base.underline, strike: props['style:text-line-through-style'] ? props['style:text-line-through-style'] !== 'none' : base.strike, + caps: capsFromOdf(props) ?? base.caps, }; } @@ -1258,6 +1265,29 @@ function mergeAdjacentText(nodes: Node[]): Node[] { return out; } +// ODF names the line's shape and how many lines it draws; CSS has one property for +// both, and a double line wins over the shape (it is what the reader sees). +const LINE_SHAPE: Record = { + dotted: 'dotted', 'dot-dash': 'dotted', 'dot-dot-dash': 'dotted', + dash: 'dashed', 'long-dash': 'dashed', + wave: 'wavy', +}; + +function lineAttrs(style: string, type: string | undefined, color: string | undefined): Record | undefined { + const attrs: Record = {}; + const shape = type === 'double' ? 'double' : LINE_SHAPE[style]; + if (shape) attrs.lineStyle = shape; + const c = color && color !== 'font-color' ? normalizeColor(color) : undefined; + if (c) attrs.lineColor = c; + return Object.keys(attrs).length ? attrs : undefined; +} + +function capsFromOdf(props: PropMap): CapsMode | null { + if (props['fo:font-variant'] === 'small-caps') return 'smallCaps'; + const t = props['fo:text-transform']; + return t === 'uppercase' || t === 'lowercase' || t === 'capitalize' ? t : null; +} + function marksFor(props: PropMap, resolver: StyleResolver, defaults: BlockDefaults): Mark[] { const marks: Mark[] = []; const textStyle: Record = {}; @@ -1276,22 +1306,32 @@ function marksFor(props: PropMap, resolver: StyleResolver, defaults: BlockDefaul const fs = props['fo:font-style']; if ((fs === 'italic' || fs === 'oblique') && !defaults.italic) marks.push({ type: 'italic' }); const ul = props['style:text-underline-style']; - if (ul && ul !== 'none' && !defaults.underline) marks.push({ type: 'underline' }); + if (ul && ul !== 'none' && !defaults.underline) { + const line = lineAttrs(ul, props['style:text-underline-type'], props['style:text-underline-color']); + marks.push(line ? { type: 'underline', attrs: line } : { type: 'underline' }); + } const lt = props['style:text-line-through-style']; - if (lt && lt !== 'none' && !defaults.strike) marks.push({ type: 'strike' }); + if (lt && lt !== 'none' && !defaults.strike) { + const line = lineAttrs(lt, props['style:text-line-through-type'], undefined); + marks.push(line ? { type: 'strike', attrs: line } : { type: 'strike' }); + } - // "super 58%" / "sub" / bare percentage (positive = raised, negative = lowered). + // "super 58%" / "sub" / bare percentage (positive = raised, negative = lowered). A + // percentage that is not one of the two presets is a freely raised run, kept in pt. const pos = props['style:text-position']; if (pos) { + const pct = parseFloat(pos); if (pos.startsWith('super')) marks.push({ type: 'superscript' }); else if (pos.startsWith('sub')) marks.push({ type: 'subscript' }); - else { - const p = parseFloat(pos); - if (p > 0) marks.push({ type: 'superscript' }); - else if (p < 0) marks.push({ type: 'subscript' }); + else if (Number.isFinite(pct) && pct) { + const size = lengthToPt(props['fo:font-size']) ?? defaults.fontSizePt; + textStyle.textPosition = Math.round((pct / 100) * size * 10) / 10; } } + const caps = capsFromOdf(props); + if (caps && caps !== defaults.caps) textStyle.caps = caps; + const bg = props['fo:background-color']; if (bg && bg !== 'transparent') { const c = normalizeColor(bg); diff --git a/src/lib/styles/styleSheet.ts b/src/lib/styles/styleSheet.ts index 7428bf7..9e479e7 100644 --- a/src/lib/styles/styleSheet.ts +++ b/src/lib/styles/styleSheet.ts @@ -3,6 +3,7 @@ // formatting on the block or run still overrides the result. import { builtinTableStyles, tableStyleCss, type TableStyle } from './tableStyles'; +import type { CapsMode } from '../editor/extensions/textEffects'; export type ParaProps = { textAlign?: 'left' | 'center' | 'right' | 'justify'; @@ -26,6 +27,7 @@ export type TextProps = { underline?: boolean; strike?: boolean; color?: string; + caps?: CapsMode; // all caps / small caps (Word w:caps + w:smallCaps, ODF fo:text-transform) }; export type Style = { @@ -234,6 +236,7 @@ export function textDeclarations(t: TextProps, asBlock = false): string[] { out.push(`text-decoration: ${[t.underline && 'underline', t.strike && 'line-through'].filter(Boolean).join(' ')}`); } if (t.color) out.push(`color: ${t.color}`); + if (t.caps) out.push(t.caps === 'smallCaps' ? 'font-variant-caps: small-caps' : `text-transform: ${t.caps}`); return out; } diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts index 2d95892..715cdf5 100644 --- a/tests/docx-roundtrip.test.ts +++ b/tests/docx-roundtrip.test.ts @@ -39,6 +39,14 @@ describe('DOCX export → import round trip', () => { text(' '), text('site', [{ type: 'link', attrs: { href: 'https://example.com' } }]), ], { spaceBefore: 6, spaceAfter: 6, lineHeight: '1.5', indent: 1 }), + // Character effects: letter case, line shapes, a freely raised run. + para([ + text('caps ', [{ type: 'textStyle', attrs: { caps: 'uppercase' } }]), + text('petite ', [{ type: 'textStyle', attrs: { caps: 'smallCaps' } }]), + text('dotted ', [{ type: 'underline', attrs: { lineStyle: 'dotted', lineColor: '#FF0000' } }]), + text('crossed ', [{ type: 'strike', attrs: { lineStyle: 'double' } }]), + text('raised', [{ type: 'textStyle', attrs: { fontSize: '14pt', textPosition: 3 } }]), + ]), { type: 'paragraph', attrs: { fontSize: '22pt', textAlign: 'center' } }, // empty sized line para([text('a\tb')]), para([text('line1'), { type: 'hardBreak' }, text('line2')]), @@ -275,6 +283,17 @@ describe('DOCX export → import round trip', () => { expect(mlSub.attrs?.listStyleType ?? null).toBe(null); }); + it('round-trips the character effects (case, line shapes, raised run)', () => { + const runs = walk(doc, 'text'); + const of = (t: string) => runs.find((r: N) => r.text === t)!.marks!; + const attrs = (t: string, type: string) => of(t).find((m: N) => m.type === type)!.attrs!; + expect(attrs('caps ', 'textStyle').caps).toBe('uppercase'); + expect(attrs('petite ', 'textStyle').caps).toBe('smallCaps'); + expect(attrs('dotted ', 'underline')).toMatchObject({ lineStyle: 'dotted', lineColor: '#FF0000' }); + expect(attrs('crossed ', 'strike').lineStyle).toBe('double'); + expect(attrs('raised', 'textStyle').textPosition).toBe(3); + }); + it('round-trips the image (size + floating wrap)', () => { const img = walk(doc, 'image')[0]; expect(img.attrs.width).toBe(100); diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 4c56c80..8bc8c37 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -57,6 +57,15 @@ const fixture: N = { T(' sup', { type: 'superscript' }), T(' sub', { type: 'subscript' }), ), + // Character effects: letter case, line shapes, a freely raised run. + P(null, + T('caps ', { type: 'textStyle', attrs: { caps: 'uppercase' } }), + T('petite ', { type: 'textStyle', attrs: { caps: 'smallCaps' } }), + T('dotted ', { type: 'underline', attrs: { lineStyle: 'dotted', lineColor: '#FF0000' } }), + T('twice ', { type: 'underline', attrs: { lineStyle: 'double' } }), + T('crossed ', { type: 'strike', attrs: { lineStyle: 'double' } }), + T('raised', { type: 'textStyle', attrs: { fontSize: '14pt', textPosition: 3 } }), + ), P(null, T('arial 14 ', { type: 'textStyle', attrs: { fontFamily: 'Arial', fontSize: '14pt' } }), T('red', { type: 'textStyle', attrs: { color: '#C00000' } }), @@ -862,7 +871,8 @@ describe('Leg 3: header/footer → buildOdt → importOdt', () => { check('hf: header distance round-trips (0.8cm)', Math.abs((hfRes.headerDistanceCm ?? 0) - 0.8) < 0.02, hfRes.headerDistanceCm); check('hf: footer distance round-trips (1.6cm)', Math.abs((hfRes.footerDistanceCm ?? 0) - 1.6) < 0.02, hfRes.footerDistanceCm); check('hf: body still round-trips alongside header/footer', - firstDiff(normalize(fixture), normalize(hfRes.content)) === null); + firstDiff(normalize(fixture), normalize(hfRes.content)) === null, + firstDiff(normalize(fixture), normalize(hfRes.content))); // Imported header/footer must be valid in the header/footer editor schema. const hfSchema = getSchema(hfExtensions());