feat: the character effects Word and LO carry beyond bold and italic

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.
This commit is contained in:
Steffen Becker committed 2026-08-08 10:06:31 +00:00
1 parent 5549d625f7
commit b61fadca48
11 files changed
+436 -33

No files matched your search

+5 -4
View File
@@ -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.
+5 -4
View File
@@ -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,
+103
View File
@@ -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<CapsMode, string> = {
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 <u> 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}` } : {}),
},
};
},
});
+35 -4
View File
@@ -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<string, unknown> | undefined): { type: (typeof UnderlineType)[keyof typeof UnderlineType]; color?: string } {
const byStyle: Record<string, (typeof UnderlineType)[keyof typeof UnderlineType]> = {
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('<w:b/>');
if (markPresent(marks, 'italic')) parts.push('<w:i/>');
if (markPresent(marks, 'strike')) parts.push('<w:strike/>');
const st = marks.find((m) => m.type === 'strike');
if (st) parts.push(st.attrs?.lineStyle === 'double' ? '<w:dstrike/>' : '<w:strike/>');
const caps = ts?.attrs?.caps;
if (caps === 'smallCaps') parts.push('<w:smallCaps/>');
else if (caps === 'uppercase') parts.push('<w:caps/>');
const pos = ts?.attrs?.textPosition;
if (typeof pos === 'number' && pos) parts.push(`<w:position w:val="${Math.round(pos * 2)}"/>`);
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(`<w:sz w:val="${hp}"/><w:szCs w:val="${hp}"/>`);
}
if (markPresent(marks, 'underline')) parts.push('<w:u w:val="single"/>');
const u = marks.find((m) => m.type === 'underline');
if (u) {
const line = wordUnderline(u.attrs);
parts.push(`<w:u w:val="${line.type}"${line.color ? ` w:color="${line.color}"` : ''}/>`);
}
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<string, number> = {};
if (p.spaceBefore != null) spacing.before = ptToTwip(p.spaceBefore);
if (p.spaceAfter != null) spacing.after = ptToTwip(p.spaceAfter);
+142 -10
View File
@@ -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 <text:p> 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 (TEF<odf attrs>TEF); applyTextEffects folds them into the run's own
// automatic text style. U+E00F.
const TEF = '\uE00F';
const EXT_BY_MIME: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
@@ -1164,6 +1171,8 @@ function ownStyleAttrs(style: { para: Record<string, unknown>; text: Record<stri
if (t.underline) text['style:text-underline-style'] = 'solid';
if (t.strike) text['style:text-line-through-style'] = 'solid';
if (t.color) text['fo:color'] = String(t.color);
if (t.caps === 'smallCaps') text['fo:font-variant'] = 'small-caps';
else if (t.caps) text['fo:text-transform'] = String(t.caps);
return { para, text };
}
@@ -2123,6 +2132,43 @@ function applyRuns(p: ParagraphBuilder | CellBuilder, content: TiptapNode[] = []
}
}
// CSS line styles → ODF's own names for the same shapes.
const ODF_LINE_STYLE: Record<string, string> = { dotted: 'dotted', dashed: 'dash', wavy: 'wave' };
// The effects ODF carries but odf-kit's run formatting has no field for. Returned as
// the <style:text-properties> 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<string, unknown
return textFormatting(resolveStyle(sheet, name, 'character').text);
}
// Prefix the sentinel on every run carrying such an effect, wherever it sits — odf-kit
// reads marks itself on its native paths, so the pre-pass is what reaches all of them.
function markTextEffects(node: TiptapNode): TiptapNode {
if (node.type === 'text') {
const extra = odfExtraTextProps(node.marks);
return extra ? { ...node, text: `${TEF}${extra}${TEF}${node.text ?? ''}` } : node;
}
if (!node.content?.length) return node;
return { ...node, content: node.content.map(markTextEffects) };
}
// odf-kit's list builder reads a run's marks itself and knows nothing about charStyle,
// and applyRuns never sees a list paragraph — so do its two jobs here: bake the style
// into direct marks (the run then gets a span) and prefix the CST sentinel for it.
@@ -2238,6 +2295,69 @@ function applyCharacterStyles(odtBytes: Uint8Array): Uint8Array {
return rezipOdt(files);
}
// Merge each TEF sentinel's effects into that run's own automatic text style (cloned,
// since the style is shared) and wrap a run that got no span in one. Shared by
// content.xml (body) and styles.xml (header/footer), which mint their styles separately.
function resolveTextEffects(xml: string, mint: (styleXml: string) => void, prefix: string): string {
if (!xml.includes(TEF)) return xml;
const nameByKey = new Map<string, string>();
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(/<style:text-properties\b([^>]*?)(\/?)>/, (_m, attrs: string, slash: string) => {
const kept = names.reduce((acc, n) => acc.replace(new RegExp(`\\s${n}="[^"]*"`, 'g'), ''), attrs);
return `<style:text-properties${kept} ${extra}${slash}>`;
})
: `<style:style style:name="${name}" style:family="text"><style:text-properties ${extra}/></style:style>`);
return name;
};
const tefRe = new RegExp(`${TEF}([^${TEF}]*)${TEF}`);
let out = xml.replace(
new RegExp(`<text:span\\b([^>]*)>([\\s\\S]*?)</text:span>`, '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 `<text:span${newAttrs}>${inner.replace(new RegExp(tefRe.source, 'g'), '')}</text:span>`;
},
);
// 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:span text:style-name="${styleFor('', extra)}">${text}</text:span>`,
);
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('</office:automatic-styles>', `${minted.join('')}</office:automatic-styles>`) : 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
// <text:line-break/> / <text:page-count> 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 <draw:frame> 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('<text:line-break/>');
styles = styles.replace(new RegExp(`${PGC}(\\d*)${PGC}`, 'g'), '<text:page-count>$1</text:page-count>');
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('</office:automatic-styles>')) styles = styles.replace('</office:automatic-styles>', `${defs}</office:automatic-styles>`);
else styles = styles.replace('<office:automatic-styles/>', `<office:automatic-styles>${defs}</office:automatic-styles>`);
}
}
if (mintedStyles.length) {
const defs = mintedStyles.join('');
if (styles.includes('</office:automatic-styles>')) styles = styles.replace('</office:automatic-styles>', `${defs}</office:automatic-styles>`);
else styles = styles.replace('<office:automatic-styles/>', `<office:automatic-styles>${defs}</office:automatic-styles>`);
}
// Resolve HFIMG sentinels (default + first-page zones) to as-char <draw:frame>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) {
+33 -2
View File
@@ -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<string, string[]> = {
@@ -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<string, LineStyle> = {
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<string, unknown> | undefined {
const attrs: Record<string, unknown> = {};
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);
+33 -1
View File
@@ -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<string, string> = {
@@ -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;
+47 -7
View File
@@ -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<string, LineStyle> = {
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<string, unknown> | undefined {
const attrs: Record<string, unknown> = {};
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<string, unknown> = {};
@@ -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);
+3
View File
@@ -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;
}
+19
View File
@@ -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);
+11 -1
View File
@@ -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());