feat: named character styles

This commit is contained in:
Steffen Becker committed 2026-07-30 23:34:23 +02:00
1 parent 603f4df905
commit 3dcae6f640
17 files changed
+492 -51

No files matched your search

+36
View File
@@ -7,6 +7,7 @@
import { effectiveOrderedTypeAt } from '../editor/extensions/orderedList';
import { isInHeaderCell } from '../editor/extensions/tableHeaderRow';
import { blockStyleName } from '../editor/extensions/paragraphStyle';
import { activeCharacterStyle } from '../editor/extensions/characterStyle';
import { DEFAULT_STYLE, resolveStyle, styleOrder } from '../styles/styleSheet';
import { styleSheet } from '../styles/sheet.svelte';
import StyleManagerDialog from './StyleManagerDialog.svelte';
@@ -24,6 +25,20 @@
// registry, assigning one only sets the style — hard formatting stays, as in Word/LO.
let sheet = $derived(styleSheet());
let galleryStyles = $derived(styleOrder(sheet));
// Character styles apply to the selected run, not the block (LibreOffice's second family).
let charStyles = $derived(Object.values(sheet.character ?? {}));
let currentCharStyle = $derived.by<string | null>(() => {
if (tick < 0 || !editor) return null;
return activeCharacterStyle(editor.state as never);
});
function applyCharStyle(name: string) {
stylesOpen = false;
if (!editor) return;
// Clicking the active one removes it, like a toggle.
if (currentCharStyle === name) editor.chain().focus().unsetCharacterStyle().run();
else editor.chain().focus().setCharacterStyle(name).run();
}
// Built-in names are translated; user styles show their own name.
function styleLabel(name: string): string {
@@ -211,6 +226,22 @@
{styleLabel(s.name)}
</button>
{/each}
{#if charStyles.length}
<div class="ol-section-label char-label">{t().styles.characterStyles}</div>
{#each charStyles as c}
{@const preview = resolveStyle(sheet, c.name, 'character')}
<button
class="ol-option style-option"
class:active={currentCharStyle === c.name}
onclick={() => applyCharStyle(c.name)}
role="menuitemradio"
aria-checked={currentCharStyle === c.name}
style="font-weight: {preview.text.bold ? 600 : 400}; font-style: {preview.text.italic ? 'italic' : 'normal'}; font-family: {preview.text.fontFamily ?? 'inherit'}"
>
{c.name}
</button>
{/each}
{/if}
<button class="ol-option manage" onclick={() => { stylesOpen = false; managerOpen = true; }}>
{t().styles.manage}
</button>
@@ -437,6 +468,11 @@
line-height: 1.2;
}
.char-label {
margin-top: 2px;
border-top: 1px solid var(--color-border);
}
.manage {
margin-top: 2px;
border-top: 1px solid var(--color-border);
+3
View File
@@ -28,6 +28,7 @@ import { ParagraphSpacing } from './extensions/paragraphSpacing';
import { ParagraphBox } from './extensions/paragraphBox';
import { BlockFontSize } from './extensions/blockFontSize';
import { ParagraphStyle } from './extensions/paragraphStyle';
import { CharacterStyle } from './extensions/characterStyle';
import { PageBreak } from './extensions/pageBreak';
import { Indent } from './extensions/indent';
import { FormattingMarks } from './extensions/formattingMarks';
@@ -101,6 +102,8 @@ export const extensions = [
// Named paragraph style (LibreOffice style:style / Word w:pStyle); the document
// stylesheet in Editor.svelte renders it, direct formatting still wins.
ParagraphStyle.configure({ sheet: styleSheet }),
// Named character style on a run (LibreOffice style:family="text" / Word w:rStyle).
CharacterStyle,
PageBreak,
Indent,
Heading.configure({ levels: [1, 2, 3, 4, 5] }),
@@ -0,0 +1,64 @@
import { Mark, mergeAttributes } from '@tiptap/core';
// A named character style on a run (LibreOffice style:family="text" / Word w:rStyle):
// paragraphStyle.ts one level down. Rendered as data-char-style for the generated
// document stylesheet; direct marks are inline styles and still win.
declare module '@tiptap/core' {
interface Commands<ReturnType> {
characterStyle: {
setCharacterStyle: (name: string) => ReturnType;
unsetCharacterStyle: () => ReturnType;
};
}
}
export const CharacterStyle = Mark.create({
name: 'charStyle',
addAttributes() {
return {
name: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute('data-char-style') || null,
renderHTML: (attributes: Record<string, unknown>) =>
attributes.name ? { 'data-char-style': String(attributes.name) } : {},
},
};
},
parseHTML() {
return [{ tag: 'span[data-char-style]' }];
},
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes), 0];
},
addCommands() {
return {
setCharacterStyle: (name: string) => ({ commands }) => commands.setMark(this.name, { name }),
unsetCharacterStyle: () => ({ commands }) => commands.unsetMark(this.name),
};
},
});
// The character style covering the selection, or null when there is none / it is mixed.
export function activeCharacterStyle(state: {
selection: { from: number; to: number; empty: boolean; $head: { marks(): readonly { type: { name: string }; attrs: Record<string, unknown> }[] } };
storedMarks?: readonly { type: { name: string }; attrs: Record<string, unknown> }[] | null;
doc: { nodesBetween(from: number, to: number, f: (node: { isText: boolean; marks: readonly { type: { name: string }; attrs: Record<string, unknown> }[] }) => void): void };
}): string | null {
const nameOf = (marks: readonly { type: { name: string }; attrs: Record<string, unknown> }[]) =>
(marks.find(m => m.type.name === 'charStyle')?.attrs.name as string | undefined) ?? null;
if (state.selection.empty) return nameOf(state.storedMarks ?? state.selection.$head.marks());
let found: string | null | undefined;
let mixed = false;
state.doc.nodesBetween(state.selection.from, state.selection.to, (node) => {
if (mixed || !node.isText) return;
const name = nameOf(node.marks);
if (found === undefined) found = name;
else if (found !== name) mixed = true;
});
return mixed ? null : found ?? null;
}
@@ -16,6 +16,7 @@ import TextAlign from '@tiptap/extension-text-align';
import { FontWeight } from './fontWeight';
import { FontColor } from './fontColor';
import { ParagraphBox } from './paragraphBox';
import { CharacterStyle } from './characterStyle';
import { PageNumber, PageCount } from './pageField';
import { Image } from './image';
@@ -55,6 +56,7 @@ export function hfExtensions(placeholder = '') {
FontColor,
// Paragraph background ("colored field") + rule line, common in letterhead headers.
ParagraphBox,
CharacterStyle,
Highlight.configure({ multicolor: true }),
HfHardBreak,
// Inline (as-character) images only — the single-paragraph zone has no text flow
+1 -1
View File
@@ -24,7 +24,7 @@ export const ParagraphStyle = Extension.create<{ types: string[]; sheet: () => S
name: 'paragraphStyle',
addOptions() {
return { types: ['paragraph', 'heading'], sheet: () => ({ paragraph: {} }) };
return { types: ['paragraph', 'heading'], sheet: () => ({ paragraph: {}, character: {} }) };
},
addGlobalAttributes() {
+21 -2
View File
@@ -10,7 +10,7 @@ import {
import type { TiptapNode } from 'odf-kit';
import type {
IRunStylePropertiesOptions, ISpacingProperties, IIndentAttributesProperties,
ILevelsOptions, IFloating, IBorderOptions, IParagraphStyleOptions,
ILevelsOptions, IFloating, IBorderOptions, IParagraphStyleOptions, ICharacterStyleOptions,
} from 'docx';
import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
@@ -158,7 +158,11 @@ function markPresent(marks: TiptapNode['marks'], type: string): boolean {
// every run (header-row cells), respecting an explicit fontWeight:normal un-bold.
function runPropsFromMarks(marks: TiptapNode['marks'] = [], forceBold = false): Writable<IRunStylePropertiesOptions> {
const ts = marks.find((m) => m.type === 'textStyle');
const props: Writable<IRunStylePropertiesOptions> = {};
const props: Writable<IRunStylePropertiesOptions> & { style?: string } = {};
// A named character style (w:rStyle); the run's own marks below still win.
const charStyle = marks.find((m) => m.type === 'charStyle')?.attrs?.name;
if (typeof charStyle === 'string' && charStyle) props.style = docxStyleId(charStyle);
let bold: boolean | undefined = markPresent(marks, 'bold') || undefined;
const fw = ts?.attrs?.fontWeight;
@@ -889,6 +893,20 @@ function paragraphStyleOf(style: Style): IParagraphStyleOptions {
};
}
// One registry character style → a Word character style (w:type="character").
function characterStyleOf(style: Style): ICharacterStyleOptions {
const t = style.text;
const run: Writable<IRunStylePropertiesOptions> = {};
if (t.fontFamily) run.font = t.fontFamily === 'Liberation Serif' ? DOC_FONT : t.fontFamily;
if (t.fontSizePt != null) run.size = Math.round(t.fontSizePt * 2);
if (t.bold != null) run.bold = t.bold;
if (t.italic != null) run.italics = t.italic;
if (t.underline) run.underline = {};
if (t.strike) run.strike = t.strike;
if (t.color) run.color = t.color.replace('#', '');
return { id: docxStyleId(style.name), name: style.name, quickFormat: true, run };
}
function buildStyles(sheet: StyleSheet, used: Set<string>, language?: { language: string; country: string } | null) {
const run: Writable<IRunStylePropertiesOptions> = { font: DOC_FONT, size: 24 };
if (language) run.language = { value: `${language.language}-${language.country}` };
@@ -898,6 +916,7 @@ function buildStyles(sheet: StyleSheet, used: Set<string>, language?: { language
},
// The document's named styles, chain intact — Word shows them in its style list.
paragraphStyles: Object.values(sheet.paragraph).filter(st => used.has(st.name)).map(paragraphStyleOf),
characterStyles: Object.values(sheet.character ?? {}).map(characterStyleOf),
};
}
+118 -8
View File
@@ -4,7 +4,7 @@ import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc } from '../storage/headerFooter';
import { builtinStyleSheet, DEFAULT_STYLE, type StyleSheet } from '../styles/styleSheet';
import { builtinStyleSheet, DEFAULT_STYLE, resolveStyle, type StyleSheet } from '../styles/styleSheet';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { BORDER_SIDES, parseBorderAttr } from '../editor/extensions/tableCellBorders';
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
@@ -111,6 +111,10 @@ const HFIMG = '';
// odf-kit path; applyParagraphStyles points the block at that style. U+E00D.
const STY = '';
// Sentinel prefixed to a run that carries a named character style (CST<name>CST);
// applyCharacterStyles points the run's span at that style. U+E00E.
const CST = '\uE00E';
const EXT_BY_MIME: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
@@ -128,9 +132,8 @@ const CELL_LIST_BULLET_STYLE = 'TblListBullet';
const CELL_LIST_NUMBER_STYLE = 'TblListNumber';
// LibreOffice's heading defaults, shown in the editor (editor.css) and written over
// odf-kit's larger Heading_20_N styles on export: its sizes plus the Heading style's
// margins, which every level inherits. import/odt.ts uses them as the defaults to
// suppress on re-import.
// odf-kit's Heading_20_N styles on export: its sizes plus the Heading style's margins.
// The importers use them as the fallback yardstick when a file declares no style.
export const HEADING_STYLE_OVERRIDES: { name: string; fontSize: string; marginTop: string; marginBottom: string }[] = [
{ name: 'Heading_20_1', fontSize: '18pt', marginTop: '0.423cm', marginBottom: '0.212cm' },
{ name: 'Heading_20_2', fontSize: '16pt', marginTop: '0.423cm', marginBottom: '0.212cm' },
@@ -225,6 +228,11 @@ function hasFontWeightRun(content: TiptapNode['content']): boolean {
);
}
// Same for a named character style: only applyRuns emits its CST sentinel.
function hasCharStyleRun(content: TiptapNode['content']): boolean {
return !!content?.some(c => c.type === 'text' && !!charStyleOf(c.marks));
}
function injectCustomTypes(node: TiptapNode, inContainer = false): TiptapNode {
// Rename tables so odf-kit routes them to our unknownNodeHandler (see
// CUST_TABLE). Recurse with inContainer=true so the cell paragraphs are NOT
@@ -239,7 +247,7 @@ function injectCustomTypes(node: TiptapNode, inContainer = false): TiptapNode {
// Don't rename paragraphs inside list items or table cells — tiptapToOdt's list
// builder (and our table handler) walk them by type === "paragraph"; renaming
// breaks that.
if (!inContainer && (hasCustomAttrs(node.attrs) || hasFontWeightRun(node.content))) {
if (!inContainer && (hasCustomAttrs(node.attrs) || hasFontWeightRun(node.content) || hasCharStyleRun(node.content))) {
if (node.type === 'paragraph') return { ...node, type: CUST_P };
if (node.type === 'heading') return { ...node, type: CUST_H };
}
@@ -1051,6 +1059,17 @@ function stripManagedProps(block: string): string {
// chain is preserved, so LibreOffice/Word show them as real, inheriting styles.
function applyNamedStyles(styles: string, sheet: StyleSheet, used: Set<string>): string {
const added: string[] = [];
// Character styles are the same shape, family "text" and text props only.
for (const style of Object.values(sheet.character ?? {})) {
const { text } = ownStyleAttrs(style);
if (!Object.keys(text).length) continue;
const odfName = odfStyleName(style.name);
if (findAutoStyle(styles, odfName)) continue;
const attrs: Record<string, string> = {
'style:name': odfName, 'style:family': 'text', 'style:display-name': style.name,
};
added.push(upsertProps(`${setTagAttrs('<style:style/>', attrs).replace('/>', '>')}</style:style>`, 'text', text));
}
for (const style of Object.values(sheet.paragraph)) {
if (!used.has(style.name)) continue;
const odfName = odfStyleName(style.name);
@@ -1859,19 +1878,109 @@ function linkHrefOf(marks: TiptapNode['marks'] = []): string | undefined {
// Emit each text node as an odf-kit run; link-marked runs become <text:a> via addLink.
// forceBold bakes bold onto every run regardless of marks — used for header-row cells,
// whose bold is presentational (CSS) in the editor and so isn't stored as a mark.
// The sheet of the export in flight, so the run emitters can resolve character styles
// without threading it through every cell/list helper (mirrors docLangTag in docx.ts).
let exportSheet: StyleSheet = builtinStyleSheet();
function applyRuns(p: ParagraphBuilder | CellBuilder, content: TiptapNode[] = [], forceBold = false) {
for (const node of content) {
if (node.type !== 'text' || !node.text) continue;
const fmt = formattingFromMarks(node.marks);
// Bake header bold, but respect an explicit un-bold (fontWeight:normal) override.
if (forceBold && fmt.fontWeight !== 'normal') fmt.bold = true;
// A named character style: bake its resolved formatting (so odf-kit always mints a
// span) and mark the run, so applyCharacterStyles can re-point that span at the style.
const charStyle = charStyleOf(node.marks);
let prefix = '';
if (charStyle) {
// The style is the base, the run's own marks stay on top.
const direct = { ...fmt };
Object.assign(fmt, charFormatting(exportSheet, charStyle), direct);
prefix = `${CST}${charStyle}${CST}`;
}
const f = Object.keys(fmt).length ? fmt : undefined;
const href = linkHrefOf(node.marks);
if (href) p.addLink(node.text, href, f);
else p.addText(node.text, f);
if (href) p.addLink(prefix + node.text, href, f);
else p.addText(prefix + node.text, f);
}
}
function charStyleOf(marks: TiptapNode['marks']): string | null {
const name = marks?.find(m => m.type === 'charStyle')?.attrs?.name;
return typeof name === 'string' && name ? name : null;
}
// A character style's resolved props as odf-kit run formatting (baked so the file still
// looks right for readers that ignore the style reference).
function charFormatting(sheet: StyleSheet, name: string): Record<string, unknown> {
const t = resolveStyle(sheet, name, 'character').text;
const fmt: Record<string, unknown> = {};
if (t.bold != null) fmt.bold = t.bold;
if (t.italic != null) fmt.italic = t.italic;
if (t.underline) fmt.underline = true;
if (t.strike) fmt.strikethrough = true;
if (t.fontFamily) fmt.fontFamily = t.fontFamily === ODFKIT_DEFAULT_FONT ? EXPORT_FONT : t.fontFamily;
if (t.fontSizePt != null) fmt.fontSize = `${t.fontSizePt}pt`;
if (t.color) fmt.color = t.color;
return fmt;
}
// Resolve CST sentinels: point each marked run's span at its named character style
// (a clone of the automatic style whose parent becomes the named one), then strip it.
function applyCharacterStyles(odtBytes: Uint8Array): Uint8Array {
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
if (!content.includes(CST)) return odtBytes;
const minted: string[] = [];
const nameByKey = new Map<string, string>();
let counter = 0;
const styleFor = (source: string, styleName: string): string => {
const odfName = odfStyleName(styleName);
const auto = source ? findAutoStyle(content, source) : null;
if (!auto) return odfName;
const key = `${source}|${odfName}`;
const existing = nameByKey.get(key);
if (existing) return existing;
const name = `TS${++counter}`;
nameByKey.set(key, name);
minted.push(
auto
.replace(/style:name="[^"]*"/, `style:name="${name}"`)
.replace(/^<style:style\b[^>]*?(\/?)>/, (tag) => setTagAttrs(tag, { 'style:parent-style-name': odfName })),
);
return name;
};
const cstRe = new RegExp(`${CST}([^${CST}]*)${CST}`);
content = content.replace(
new RegExp(`<text:span\\b([^>]*)>([\\s\\S]*?)</text:span>`, 'g'),
(m, attrs: string, inner: string) => {
const sm = cstRe.exec(inner);
if (!sm) return m;
const cleaned = inner.replace(cstRe, '');
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}>${cleaned}</text:span>`;
},
);
content = content.replace(new RegExp(`${CST}[^${CST}]*${CST}`, 'g'), '');
if (minted.length) {
content = content.replace('</office:automatic-styles>', `${minted.join('')}</office:automatic-styles>`);
}
files['content.xml'] = strToU8(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).
@@ -2459,6 +2568,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
// Text boxes and columns sections are hoisted after replacePageBreaks (so PGB never
// lands on their blocks) and before the inline passes (which then cover the hoisted
// blocks too).
exportSheet = styles;
const images: ImageExport[] = [];
const tocs: TocExport[] = [];
const textBoxes: TextBoxExport[] = [];
@@ -2636,7 +2746,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const withPageBreaks = applyPageBreaks(withToc);
const withEmptyFontSizes = applyEmptyLineFontSizes(withPageBreaks);
const withParaBoxes = applyParagraphBoxes(withEmptyFontSizes);
const withNamedStyles = applyParagraphStyles(withParaBoxes);
const withNamedStyles = applyCharacterStyles(applyParagraphStyles(withParaBoxes));
const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles));
return applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages, evenHeaderPara, evenFooterPara);
}
+1
View File
@@ -71,6 +71,7 @@ const de: Messages = {
styles: {
title: 'Absatzvorlagen',
manage: 'Vorlagen verwalten…',
characterStyles: 'Zeichenvorlagen',
custom: 'eigen',
inherited: 'geerbt',
abstract: 'nur Elternstil',
+1
View File
@@ -75,6 +75,7 @@ const en = {
styles: {
title: 'Paragraph styles',
manage: 'Manage styles…',
characterStyles: 'Character styles',
custom: 'own',
inherited: 'inherited',
abstract: 'parent only',
+29 -6
View File
@@ -32,6 +32,8 @@ type Ctx = {
// Word styleId → registry name, and the ids blocks actually reference.
styleNames: Map<string, string>;
usedStyles: Set<string>;
charStyleNames: Map<string, string>;
usedCharStyles: Set<string>;
warnings: Set<string>;
files: Record<string, Uint8Array>;
rels: Map<string, RelInfo>;
@@ -112,7 +114,8 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
const styleNames = new Map<string, string>();
const defaultStyleId = styles.defaultParagraphStyle();
for (const [id, def] of styles.namedParagraphStyles()) styleNames.set(id, registryName(id, def.name, id === defaultStyleId));
const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map() };
const charStyleNames = styles.namedCharacterStyles();
const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map() };
const body = docDoc.getElementsByTagNameNS(W, 'body')[0];
if (!body) throw new Error('Not a Word document (no w:body).');
@@ -583,9 +586,11 @@ function stylePara(ctx: Ctx, id: string | null): ParaProps {
return out;
}
function styleText(ctx: Ctx, id: string | null): TextProps {
// `own` reads the style's own chain without docDefaults — a character style adds to the
// paragraph's formatting, so the document defaults are not its properties.
function styleText(ctx: Ctx, id: string | null, own = false): TextProps {
if (!id) return {};
const run = ctx.styles.paragraphRun(id);
const run = own ? ctx.styles.styleOwn(id) : ctx.styles.paragraphRun(id);
const out: TextProps = {};
// Our own export declares the metric twin; keep the registry on the on-screen name.
if (run.font) out.fontFamily = run.font === 'Times New Roman' ? 'Liberation Serif'
@@ -633,6 +638,14 @@ function collectStyleSheet(ctx: Ctx): StyleSheet {
if (level) style.outlineLevel = Number(level[1]);
sheet.paragraph[name] = style;
}
for (const id of ctx.usedCharStyles) {
const name = ctx.charStyleNames.get(id) ?? id;
const builtin = sheet.character[name];
sheet.character[name] = {
name, parent: null, next: null, builtin: builtin?.builtin,
para: {}, text: styleText(ctx, id, true),
};
}
return sheet;
}
@@ -805,14 +818,24 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
const runMarks = (r: Element, linkHref?: string): Mark[] => {
const rPr = fc(r, 'rPr');
const rStyle = fc(rPr, 'rStyle');
const props = mergeRunProps(mergeRunProps(baseRun, ctx.styles.styleOwn(rStyle ? wVal(rStyle) : null)), parseRunProps(rPr));
const charId = rStyle ? wVal(rStyle) : null;
const charName = charId ? ctx.charStyleNames.get(charId) : undefined;
// A named character style: its formatting belongs to the style, so it joins the
// yardstick and the run only keeps what goes beyond it.
const styleRun = ctx.styles.styleOwn(charId);
const runDefaults = charName ? blockDefaults(mergeRunProps(baseRun, styleRun), null, defaults.boldByDefault) : defaults;
const props = mergeRunProps(mergeRunProps(baseRun, styleRun), parseRunProps(rPr));
// No font resolved anywhere: fall back to the document's own theme (not the editor
// default) — Word's implicit default is the minor font for body text, the major one
// for headings.
if (!props.font) {
props.font = ctx.styles.themeFont(props.fontTheme ?? (defaults.boldByDefault ? 'major' : 'minor'));
props.font = ctx.styles.themeFont(props.fontTheme ?? (runDefaults.boldByDefault ? 'major' : 'minor'));
}
const marks = marksFor(props, runDefaults, !!linkHref);
if (charName) {
ctx.usedCharStyles.add(charId!);
marks.push({ type: 'charStyle', attrs: { name: charName } });
}
const marks = marksFor(props, defaults, !!linkHref);
if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } });
return marks;
};
+11 -2
View File
@@ -99,6 +99,7 @@ export class DocxStyles {
private ownSpacing = new Map<string, ParaSpacing>(); // style's own w:pPr/w:spacing
private ownIndentTwip = new Map<string, number>(); // style's own w:pPr/w:ind left
private paraStyleNames = new Map<string, string>(); // paragraph styleId → w:name
private charStyleNames = new Map<string, string>(); // character styleId → w:name
private defaultParaStyle: string | null = null; // the w:default="1" paragraph style
private defaultsAlign: string | null = null; // docDefaults w:pPrDefault/w:jc
private defaultsSpacing: ParaSpacing = {}; // docDefaults w:pPrDefault/w:spacing
@@ -163,9 +164,12 @@ export class DocxStyles {
const left = parseInt(ind.getAttributeNS(W, 'left') ?? ind.getAttributeNS(W, 'start') ?? '', 10);
if (Number.isFinite(left)) this.ownIndentTwip.set(id, left);
}
if (style.getAttributeNS(W, 'type') === 'paragraph') {
const kind = style.getAttributeNS(W, 'type');
if (kind === 'paragraph' || kind === 'character') {
const nameEl = firstChild(style, 'name');
this.paraStyleNames.set(id, (nameEl && wVal(nameEl)) || id);
const name = (nameEl && wVal(nameEl)) || id;
if (kind === 'paragraph') this.paraStyleNames.set(id, name);
else this.charStyleNames.set(id, name);
}
}
}
@@ -177,6 +181,11 @@ export class DocxStyles {
return out;
}
// Character styles defined in the file (w:type="character"), for the style registry.
namedCharacterStyles(): Map<string, string> {
return new Map(this.charStyleNames);
}
// The style's effective left indent (twips) along the basedOn chain.
styleIndentTwip(styleId: string | null | undefined, seen = new Set<string>()): number | null {
if (!styleId || seen.has(styleId)) return null;
+53 -4
View File
@@ -73,6 +73,8 @@ type Ctx = {
// document's style registry is built from these (only used styles are kept).
styleNames: Map<string, string>;
usedStyles: Set<string>;
charStyleNames: Map<string, string>;
usedCharStyles: Set<string>;
warnings: Set<string>;
files: Record<string, Uint8Array>;
imageCache: Map<string, string>;
@@ -345,7 +347,9 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
const styleNames = new Map<string, string>();
for (const [name, def] of resolver.namedParagraphStyles()) styleNames.set(name, displayStyleName(name, def.display));
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [] };
const charStyleNames = new Map<string, string>();
for (const [name, def] of resolver.namedTextStyles()) charStyleNames.set(name, displayStyleName(name, def.display));
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [] };
let blocks = convertBlocks(Array.from(body.children), ctx, 'body');
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
@@ -797,9 +801,40 @@ function collectStyleSheet(resolver: StyleResolver, ctx: Ctx): StyleSheet {
if (level) style.outlineLevel = Number(level[1]);
sheet.paragraph[name] = style;
}
for (const odfName of ctx.usedCharStyles) {
const name = ctx.charStyleNames.get(odfName) ?? odfName;
const builtin = sheet.character[name];
sheet.character[name] = {
name, parent: null, next: null, builtin: builtin?.builtin,
para: {}, text: textPropsFromOdf(resolver.spanTextProps(odfName), resolver),
};
}
return sheet;
}
// The block's yardstick plus what the run's character style provides.
function charDefaults(ctx: Ctx, base: BlockDefaults, odfName: string): BlockDefaults {
const props = ctx.resolver.spanTextProps(odfName);
const family = ctx.resolver.fontFamilyOf(props)?.toLowerCase();
const fonts = new Set(base.fonts);
if (family) {
fonts.add(family);
for (const twin of FONT_TWINS[family] ?? []) fonts.add(twin);
}
const weight = props['fo:font-weight'];
const fontStyle = props['fo:font-style'];
return {
...base,
fontSizePt: lengthToPt(props['fo:font-size']) ?? base.fontSizePt,
boldByDefault: weight ? weight === 'bold' || parseInt(weight, 10) >= 600 : base.boldByDefault,
fonts,
color: (props['fo:color'] && normalizeColor(props['fo:color'])) || base.color,
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,
};
}
function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault = false): Node {
const { resolver } = ctx;
const styleName = el.getAttributeNS(NS.text, 'style-name');
@@ -1008,6 +1043,10 @@ function numberStyleKind(tokens: Token[]): 'date' | 'time' | null {
function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: BlockDefaults, hfFields = false): Node[] {
const out: Node[] = [];
// The named character style of the enclosing span, if any: its formatting belongs to
// the style, so the run only keeps what goes beyond it.
let charStyle: string | null = null;
const pushText = (text: string, props: PropMap, linkHref?: string) => {
// Strip our export sentinels (SEG/LBR) defensively — never legitimate text.
let clean = text.replace(/[-]/g, '');
@@ -1018,7 +1057,12 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
clean = clean.replace(/[ \t]*\n[ \t]*/g, ' ');
}
if (!clean) return;
const marks = marksFor(props, ctx.resolver, defaults);
const marks = marksFor(props, ctx.resolver, charStyle ? charDefaults(ctx, defaults, charStyle) : defaults);
if (charStyle) {
const display = ctx.charStyleNames.get(charStyle) ?? charStyle;
ctx.usedCharStyles.add(charStyle);
marks.push({ type: 'charStyle', attrs: { name: display } });
}
if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } });
const node: Node = { type: 'text', text: clean };
if (marks.length) node.marks = marks;
@@ -1036,9 +1080,14 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
if (e.namespaceURI === NS.text) {
switch (e.localName) {
case 'span':
walk(e, layerTextProps(props, ctx.resolver.spanTextProps(e.getAttributeNS(NS.text, 'style-name'))), linkHref);
case 'span': {
const spanStyle = e.getAttributeNS(NS.text, 'style-name');
const outer = charStyle;
charStyle = ctx.resolver.namedAncestor(spanStyle, 'text') ?? outer;
walk(e, layerTextProps(props, ctx.resolver.spanTextProps(spanStyle)), linkHref);
charStyle = outer;
continue;
}
case 's': {
const c = parseInt(e.getAttributeNS(NS.text, 'c') ?? '1', 10);
pushText(' '.repeat(Number.isFinite(c) && c > 0 ? c : 1), props, linkHref);
+18 -5
View File
@@ -137,6 +137,7 @@ export class StyleResolver {
private mergedCache = new Map<string, { text: PropMap; para: PropMap; misc: PropMap }>();
private stylesDoc: Document | null;
private namedParagraphNames = new Set<string>();
private namedTextNames = new Set<string>();
private displayNames = new Map<string, string>();
constructor(contentDoc: Document, stylesDoc: Document | null) {
@@ -170,17 +171,28 @@ export class StyleResolver {
// The nearest named style in a style's parent chain (an automatic style is direct
// formatting layered on top of it); null when the chain reaches none.
namedAncestor(styleName: string | null | undefined): string | null {
namedAncestor(styleName: string | null | undefined, family: 'paragraph' | 'text' = 'paragraph'): string | null {
const named = family === 'text' ? this.namedTextNames : this.namedParagraphNames;
let cur = styleName ?? null;
const seen = new Set<string>();
while (cur && !seen.has(cur)) {
seen.add(cur);
if (this.namedParagraphNames.has(cur)) return cur;
cur = this.styles.get(`paragraph\0${cur}`)?.parent ?? null;
if (named.has(cur)) return cur;
cur = this.styles.get(`${family}\0${cur}`)?.parent ?? null;
}
return null;
}
// Named text (character) styles from <office:styles>, own props only.
namedTextStyles(): Map<string, { display?: string; text: PropMap }> {
const out = new Map<string, { display?: string; text: PropMap }>();
for (const name of this.namedTextNames) {
const entry = this.styles.get(`text\0${name}`);
if (entry) out.set(name, { display: this.displayNames.get(name), text: entry.text });
}
return out;
}
private scanFontFaces(container: Element): void {
for (const el of Array.from(container.children)) {
if (el.namespaceURI !== NS.style || el.localName !== 'font-face') continue;
@@ -212,8 +224,9 @@ export class StyleResolver {
const name = el.getAttributeNS(NS.style, 'name');
const family = el.getAttributeNS(NS.style, 'family');
if (name && family) this.styles.set(`${family}\0${name}`, entryFromStyleElement(el));
if (name && family === 'paragraph' && named) {
this.namedParagraphNames.add(name);
if (name && named && (family === 'paragraph' || family === 'text')) {
if (family === 'paragraph') this.namedParagraphNames.add(name);
else this.namedTextNames.add(name);
const display = el.getAttributeNS(NS.style, 'display-name');
if (display) this.displayNames.set(name, display);
}
+19 -9
View File
@@ -2,7 +2,7 @@
// components read styleSheet() in a template or $derived and re-render when it is
// replaced. Persisted to localStorage like the other document side-cars.
import { builtinStyleSheet, DEFAULT_STYLE, mergeStoredSheet, STYLE_SHEET_VERSION, type Style, type StyleSheet } from './styleSheet';
import { builtinStyleSheet, DEFAULT_STYLE, mergeStoredSheet, STYLE_SHEET_VERSION, type Style, type StyleFamily, type StyleSheet } from './styleSheet';
const STORAGE_KEY = 'odf-editor-styles';
@@ -27,9 +27,10 @@ export function setStyleSheet(next: StyleSheet): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ v: STYLE_SHEET_VERSION, ...next }));
}
// Replace one style (add or edit) — every block using it re-renders via styleCss.
export function putStyle(style: Style): void {
setStyleSheet({ paragraph: { ...current.paragraph, [style.name]: style } });
// Replace one style (add or edit) — everything using it re-renders via styleCss.
export function putStyle(style: Style, family: StyleFamily = 'paragraph'): void {
const key = family === 'character' ? 'character' : 'paragraph';
setStyleSheet({ ...current, [key]: { ...current[key], [style.name]: style } });
}
// Rename a style and re-point everything that referenced it (children, next-styles).
@@ -43,7 +44,7 @@ export function renameStyle(from: string, to: string): void {
paragraph[name] = { ...s, parent: s.parent === from ? to : s.parent, next: s.next === from ? to : s.next };
}
paragraph[to] = { ...style, name: to, builtin: undefined };
setStyleSheet({ paragraph });
setStyleSheet({ ...current, paragraph });
}
// Delete a style; its children re-parent to its own parent, as in LibreOffice.
@@ -59,11 +60,20 @@ export function deleteStyle(name: string): void {
next: s.next === name ? style.next ?? DEFAULT_STYLE : s.next,
};
}
setStyleSheet({ paragraph });
setStyleSheet({ ...current, paragraph });
}
// Restore a built-in to its factory definition.
export function resetStyle(name: string): void {
const factory = builtinStyleSheet().paragraph[name];
if (factory) putStyle(factory);
export function resetStyle(name: string, family: StyleFamily = 'paragraph'): void {
const base = builtinStyleSheet();
const factory = (family === 'character' ? base.character : base.paragraph)[name];
if (factory) putStyle(factory, family);
}
// Character styles have no children to re-parent, so removal is a plain delete.
export function deleteCharacterStyle(name: string): void {
if (current.character[name]?.builtin) return;
const character = { ...current.character };
delete character[name];
setStyleSheet({ ...current, character });
}
+38 -14
View File
@@ -1,7 +1,6 @@
// Named paragraph styles with inheritance, modelled on LibreOffice: a style has a
// name, a parent it inherits from, a follow-on style, and two property groups
// (paragraph layout + text). Resolution walks the parent chain, nearer wins; direct
// formatting on the block (attrs/marks) still overrides the result.
// Named styles with inheritance, modelled on LibreOffice: a name, a parent, a follow-on
// style and two property groups. Resolution walks the parent chain (nearer wins); direct
// formatting on the block or run still overrides the result.
export type ParaProps = {
textAlign?: 'left' | 'center' | 'right' | 'justify';
@@ -39,7 +38,13 @@ export type Style = {
text: TextProps;
};
export type StyleSheet = { paragraph: Record<string, Style> };
// Two families, as in LibreOffice: paragraph styles govern whole blocks, character
// styles a run of text inside one. Same Style shape; a character style only uses `text`.
export type StyleFamily = 'paragraph' | 'character';
export type StyleSheet = {
paragraph: Record<string, Style>;
character: Record<string, Style>;
};
export const DEFAULT_STYLE = 'Standard';
export const HEADING_PARENT = 'Heading';
@@ -65,6 +70,13 @@ const BUILTINS: Style[] = [
para: { indent: 1, spaceAfter: 14 }, text: {} },
];
// LibreOffice's character styles (its "Emphasis"/"Strong Emphasis"/"Source Text").
const CHAR_BUILTINS: Style[] = [
{ name: 'Emphasis', parent: null, next: null, builtin: true, para: {}, text: { italic: true } },
{ name: 'Strong Emphasis', parent: null, next: null, builtin: true, para: {}, text: { bold: true } },
{ name: 'Source Text', parent: null, next: null, builtin: true, para: {}, text: { fontFamily: 'Courier New' } },
];
// Bumped whenever the built-in definitions change: a stored sheet from an older version
// keeps its user styles but takes the new factory built-ins (see mergeStoredSheet).
export const STYLE_SHEET_VERSION = 2;
@@ -73,13 +85,15 @@ export const STYLE_SHEET_VERSION = 2;
// (a document's own styles, and edits to built-ins). Older: only user styles survive.
export function mergeStoredSheet(stored: unknown): StyleSheet {
const sheet = builtinStyleSheet();
const data = stored as { v?: number; paragraph?: Record<string, Style> } | null;
const data = stored as { v?: number; paragraph?: Record<string, Style>; character?: Record<string, Style> } | null;
if (!data?.paragraph || typeof data.paragraph !== 'object') return sheet;
const current = data.v === STYLE_SHEET_VERSION;
for (const [name, style] of Object.entries(data.paragraph)) {
if (!style || typeof style !== 'object') continue;
if (!current && style.builtin) continue;
sheet.paragraph[name] = style;
for (const family of ['paragraph', 'character'] as const) {
for (const [name, style] of Object.entries(data[family] ?? {})) {
if (!style || typeof style !== 'object') continue;
if (!current && style.builtin) continue;
sheet[family][name] = style;
}
}
return sheet;
}
@@ -87,7 +101,9 @@ export function mergeStoredSheet(stored: unknown): StyleSheet {
export function builtinStyleSheet(): StyleSheet {
const paragraph: Record<string, Style> = {};
for (const s of BUILTINS) paragraph[s.name] = structuredClone(s);
return { paragraph };
const character: Record<string, Style> = {};
for (const s of CHAR_BUILTINS) character[s.name] = structuredClone(s);
return { paragraph, character };
}
// The gallery order: built-ins as listed above, user styles after them. `Heading` is an
@@ -114,13 +130,15 @@ export type ResolvedStyle = { para: ParaProps; text: TextProps };
// Flattened props of a style: the parent chain applied root-first, so the nearest
// definition wins. Cycles and missing parents end the walk.
export function resolveStyle(sheet: StyleSheet, name: string | null | undefined): ResolvedStyle {
export function resolveStyle(sheet: StyleSheet, name: string | null | undefined, family: StyleFamily = 'paragraph'): ResolvedStyle {
const styles = family === 'character' ? sheet.character : sheet.paragraph;
const chain: Style[] = [];
const seen = new Set<string>();
let cur = name && sheet.paragraph[name] ? name : DEFAULT_STYLE;
// A character style with no definition contributes nothing; a paragraph always has Standard.
let cur = name && styles[name] ? name : family === 'character' ? '' : DEFAULT_STYLE;
while (cur && !seen.has(cur)) {
seen.add(cur);
const style = sheet.paragraph[cur];
const style = styles[cur];
if (!style) break;
chain.push(style);
cur = style.parent ?? '';
@@ -176,6 +194,12 @@ function declarations(r: ResolvedStyle): string[] {
// Specificity beats editor.css's `.paper .tiptap hN`; inline attrs/marks still win.
export function styleCss(sheet: StyleSheet): string {
const rules: string[] = [];
for (const style of Object.values(sheet.character ?? {})) {
const decls = declarations(resolveStyle(sheet, style.name, 'character'));
if (!decls.length) continue;
const attr = `[data-char-style="${style.name.replace(/"/g, '\\"')}"]`;
rules.push(`.paper .tiptap ${attr} {\n ${decls.join(';\n ')};\n}`);
}
for (const style of Object.values(sheet.paragraph)) {
const decls = declarations(resolveStyle(sheet, style.name));
if (!decls.length) continue;
+34
View File
@@ -965,3 +965,37 @@ describe('DOCX named paragraph styles', () => {
expect(style.para.spaceBefore).toBe(8);
});
});
describe('DOCX named character styles', () => {
const sheet = builtinStyleSheet();
sheet.character['Signal'] = {
name: 'Signal', parent: null, next: null, para: {}, text: { bold: true, color: '#CC0000' },
};
const CS = (t: string, name: string, ...extra: any[]) =>
({ type: 'text', text: t, marks: [{ type: 'charStyle', attrs: { name } }, ...extra] });
it('writes w:rStyle runs and round-trips them', async () => {
const fixture = { type: 'doc', content: [{ type: 'paragraph', content: [
text('plain '), CS('emphasised', 'Emphasis'), text(' and '), CS('signal', 'Signal', { type: 'italic' }),
] }] } as any;
const bytes = await buildDocx(fixture, undefined, undefined, undefined, undefined, undefined, sheet);
const files = unzipSync(bytes);
const stylesXml = strFromU8(files['word/styles.xml']);
const documentXml = strFromU8(files['word/document.xml']);
expect(stylesXml).toMatch(/<w:style w:type="character" [^>]*w:styleId="Signal"/);
expect(stylesXml).toMatch(/w:styleId="Signal"[\s\S]*?<w:color w:val="CC0000"\/>/);
expect(documentXml).toContain('<w:rStyle w:val="Emphasis"/>');
expect(documentXml).toContain('<w:rStyle w:val="Signal"/>');
const res = importDocx(bytes);
const runs = (res.content as N).content![0].content!;
const charOf = (r: N) => (r.marks ?? []).find((m: N) => m.type === 'charStyle')?.attrs?.name;
expect(runs[0].marks).toBeUndefined();
expect(charOf(runs[1])).toBe('Emphasis');
expect((runs[1].marks ?? []).every((m: N) => m.type === 'charStyle')).toBe(true);
const signal = runs.find((r: N) => charOf(r) === 'Signal')!;
expect((signal.marks ?? []).some((m: N) => m.type === 'italic')).toBe(true);
expect(res.styles.character['Signal'].text).toEqual({ bold: true, color: '#CC0000' });
});
});
+43
View File
@@ -1359,3 +1359,46 @@ describe('Leg 12: named paragraph styles (ODF)', () => {
check('heading needs no direct formatting', !blocks[0]?.content?.[0]?.marks, blocks[0]);
});
});
describe('Leg 13: named character styles (ODF)', () => {
const sheet = builtinStyleSheet();
sheet.character['Signal'] = {
name: 'Signal', parent: null, next: null, para: {}, text: { bold: true, color: '#CC0000' },
};
const CS = (text: string, name: string, ...extra: N[]): N =>
({ type: 'text', text, marks: [{ type: 'charStyle', attrs: { name } }, ...extra] });
const doc: N = {
type: 'doc',
content: [P(null, T('plain '), CS('emphasised', 'Emphasis'), T(' and '), CS('signal', 'Signal', { type: 'italic' }))],
};
it('writes style:family="text" styles and round-trips the run assignment', async () => {
const bytes = await buildOdt(doc, margins, 'portrait', undefined, null, 'A4', sheet);
const files = unzipSync(bytes);
const styles = strFromU8(files['styles.xml']);
const content = strFromU8(files['content.xml']);
check('mints the built-in Emphasis as a text style',
/<style:style style:name="Emphasis" style:family="text"[\s\S]*?fo:font-style="italic"/.test(styles), styles.slice(0, 200));
check('mints the user character style', /style:name="Signal" style:family="text"/.test(styles));
// The run's span points at the style; its own formatting is baked alongside.
check('run references the style via its automatic style',
/<style:style style:name="TS\d+"[^>]*style:parent-style-name="Signal"/.test(content), content.slice(0, 400));
check('no leftover sentinel', !content.includes('\uE00E'));
const runs = (importOdt(bytes).content.content?.[0] as N).content as N[];
const charOf = (r: N) => (r.marks ?? []).find((m: N) => m.type === 'charStyle')?.attrs?.name;
check('plain run stays plain', !runs[0].marks, runs[0]);
check('built-in style round-trips', charOf(runs[1]) === 'Emphasis', runs[1]);
check('style formatting is not copied onto the run',
(runs[1].marks ?? []).every((m: N) => m.type === 'charStyle'), runs[1]);
const signal = runs.find((r: N) => charOf(r) === 'Signal');
check('user style round-trips', !!signal, runs);
check('direct formatting on top survives',
(signal?.marks ?? []).some((m: N) => m.type === 'italic'), signal);
const imported = importOdt(bytes).styles.character['Signal'];
check('the character style itself round-trips',
imported?.text.bold === true && imported?.text.color === '#CC0000', imported);
});
});