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:
1 parent
5549d625f7
commit
b61fadca48
11 files changed
+436
-33
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user