feat: named character styles
This commit is contained in:
1 parent
603f4df905
commit
3dcae6f640
17 files changed
+492
-51
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ const de: Messages = {
|
||||
styles: {
|
||||
title: 'Absatzvorlagen',
|
||||
manage: 'Vorlagen verwalten…',
|
||||
characterStyles: 'Zeichenvorlagen',
|
||||
custom: 'eigen',
|
||||
inherited: 'geerbt',
|
||||
abstract: 'nur Elternstil',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user