diff --git a/src/lib/components/Toolbar.svelte b/src/lib/components/Toolbar.svelte index 3de9b91..7823618 100644 --- a/src/lib/components/Toolbar.svelte +++ b/src/lib/components/Toolbar.svelte @@ -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(() => { + 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)} {/each} + {#if charStyles.length} +
{t().styles.characterStyles}
+ {#each charStyles as c} + {@const preview = resolveStyle(sheet, c.name, 'character')} + + {/each} + {/if} @@ -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); diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index dd3b18c..33d8b8c 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -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] }), diff --git a/src/lib/editor/extensions/characterStyle.ts b/src/lib/editor/extensions/characterStyle.ts new file mode 100644 index 0000000..517a70f --- /dev/null +++ b/src/lib/editor/extensions/characterStyle.ts @@ -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 { + 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) => + 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 }[] } }; + storedMarks?: readonly { type: { name: string }; attrs: Record }[] | null; + doc: { nodesBetween(from: number, to: number, f: (node: { isText: boolean; marks: readonly { type: { name: string }; attrs: Record }[] }) => void): void }; +}): string | null { + const nameOf = (marks: readonly { type: { name: string }; attrs: Record }[]) => + (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; +} diff --git a/src/lib/editor/extensions/headerFooter.ts b/src/lib/editor/extensions/headerFooter.ts index 888b57a..5f56349 100644 --- a/src/lib/editor/extensions/headerFooter.ts +++ b/src/lib/editor/extensions/headerFooter.ts @@ -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 diff --git a/src/lib/editor/extensions/paragraphStyle.ts b/src/lib/editor/extensions/paragraphStyle.ts index b4ece09..feb1219 100644 --- a/src/lib/editor/extensions/paragraphStyle.ts +++ b/src/lib/editor/extensions/paragraphStyle.ts @@ -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() { diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 324b6d6..409b8cf 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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 { const ts = marks.find((m) => m.type === 'textStyle'); - const props: Writable = {}; + const props: Writable & { 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 = {}; + 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, language?: { language: string; country: string } | null) { const run: Writable = { font: DOC_FONT, size: 24 }; if (language) run.language = { value: `${language.language}-${language.country}` }; @@ -898,6 +916,7 @@ function buildStyles(sheet: StyleSheet, used: Set, 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), }; } diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 042e37b..79db6fb 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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 (CSTCST); +// applyCharacterStyles points the run's span at that style. U+E00E. +const CST = '\uE00E'; + const EXT_BY_MIME: Record = { '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 { 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 = { + 'style:name': odfName, 'style:family': 'text', 'style:display-name': style.name, + }; + added.push(upsertProps(`${setTagAttrs('', attrs).replace('/>', '>')}`, '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 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 { + const t = resolveStyle(sheet, name, 'character').text; + const fmt: Record = {}; + 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(); + 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(/^]*?(\/?)>/, (tag) => setTagAttrs(tag, { 'style:parent-style-name': odfName })), + ); + return name; + }; + + const cstRe = new RegExp(`${CST}([^${CST}]*)${CST}`); + content = content.replace( + new RegExp(`]*)>([\\s\\S]*?)`, '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 `${cleaned}`; + }, + ); + content = content.replace(new RegExp(`${CST}[^${CST}]*${CST}`, 'g'), ''); + + if (minted.length) { + content = content.replace('', `${minted.join('')}`); + } + + 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 // / 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); } diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index d0083ca..5d352bc 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -71,6 +71,7 @@ const de: Messages = { styles: { title: 'Absatzvorlagen', manage: 'Vorlagen verwalten…', + characterStyles: 'Zeichenvorlagen', custom: 'eigen', inherited: 'geerbt', abstract: 'nur Elternstil', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index fb9facb..a8cbbed 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -75,6 +75,7 @@ const en = { styles: { title: 'Paragraph styles', manage: 'Manage styles…', + characterStyles: 'Character styles', custom: 'own', inherited: 'inherited', abstract: 'parent only', diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index d192b84..9f6627b 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -32,6 +32,8 @@ type Ctx = { // Word styleId → registry name, and the ids blocks actually reference. styleNames: Map; usedStyles: Set; + charStyleNames: Map; + usedCharStyles: Set; warnings: Set; files: Record; rels: Map; @@ -112,7 +114,8 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = const styleNames = new Map(); 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; }; diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts index a8d675e..10df6df 100644 --- a/src/lib/import/docxStyles.ts +++ b/src/lib/import/docxStyles.ts @@ -99,6 +99,7 @@ export class DocxStyles { private ownSpacing = new Map(); // style's own w:pPr/w:spacing private ownIndentTwip = new Map(); // style's own w:pPr/w:ind left private paraStyleNames = new Map(); // paragraph styleId → w:name + private charStyleNames = new Map(); // 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 { + return new Map(this.charStyleNames); + } + // The style's effective left indent (twips) along the basedOn chain. styleIndentTwip(styleId: string | null | undefined, seen = new Set()): number | null { if (!styleId || seen.has(styleId)) return null; diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index b60186d..3105b6c 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -73,6 +73,8 @@ type Ctx = { // document's style registry is built from these (only used styles are kept). styleNames: Map; usedStyles: Set; + charStyleNames: Map; + usedCharStyles: Set; warnings: Set; files: Record; imageCache: Map; @@ -345,7 +347,9 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = const styleNames = new Map(); 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(); + 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); diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 929774f..9818508 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -137,6 +137,7 @@ export class StyleResolver { private mergedCache = new Map(); private stylesDoc: Document | null; private namedParagraphNames = new Set(); + private namedTextNames = new Set(); private displayNames = new Map(); 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(); 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 , own props only. + namedTextStyles(): Map { + const out = new Map(); + 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); } diff --git a/src/lib/styles/sheet.svelte.ts b/src/lib/styles/sheet.svelte.ts index 878ac4e..45f2694 100644 --- a/src/lib/styles/sheet.svelte.ts +++ b/src/lib/styles/sheet.svelte.ts @@ -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 }); } diff --git a/src/lib/styles/styleSheet.ts b/src/lib/styles/styleSheet.ts index 288c056..13d43dc 100644 --- a/src/lib/styles/styleSheet.ts +++ b/src/lib/styles/styleSheet.ts @@ -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 }; +// 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; + character: Record; +}; 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 } | null; + const data = stored as { v?: number; paragraph?: Record; character?: Record } | 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 = {}; for (const s of BUILTINS) paragraph[s.name] = structuredClone(s); - return { paragraph }; + const character: Record = {}; + 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(); - 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; diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts index 765ca07..d2cf3bf 100644 --- a/tests/docx-roundtrip.test.ts +++ b/tests/docx-roundtrip.test.ts @@ -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:styleId="Signal"/); + expect(stylesXml).toMatch(/w:styleId="Signal"[\s\S]*?/); + expect(documentXml).toContain(''); + expect(documentXml).toContain(''); + + 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' }); + }); +}); diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index a7df951..4cd18ae 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -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: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); + }); +});