feat: odf import/export of named paragraph styles
This commit is contained in:
1 parent
ac9d6cdcff
commit
d79287c10a
10 files changed
+575
-77
No files matched your search
+5
-2
@@ -23,6 +23,7 @@
|
||||
import { loadPageMargins, savePageMargins, DEFAULT_MARGINS, type PageMargins } from './lib/storage/pageMargins';
|
||||
import { loadOrientation, saveOrientation, type Orientation } from './lib/storage/pageOrientation';
|
||||
import { loadPageFormat, savePageFormat, type PageFormat } from './lib/storage/pageFormat';
|
||||
import { setStyleSheet, styleSheet } from './lib/styles/sheet.svelte';
|
||||
import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, loadDifferentFirstPage, saveDifferentFirstPage, loadDifferentOddEven, saveDifferentOddEven, hfIsEmpty, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from './lib/storage/headerFooter';
|
||||
import { loadDocName, saveDocName, stripOdtExtension, sanitizeNameForFile } from './lib/storage/documentName';
|
||||
import { loadDocumentLanguage, saveDocumentLanguage, odfFromLanguage, type DocumentLanguage } from './lib/storage/documentLanguage';
|
||||
@@ -443,6 +444,8 @@
|
||||
// Adopt the document's spell-check language (the $effect switches the
|
||||
// controller + loads its dictionary). null = file declared none; keep ours.
|
||||
if (result.language) documentLanguage = result.language;
|
||||
// Adopt the document's named paragraph styles (built-ins + the file's own).
|
||||
setStyleSheet(result.styles);
|
||||
// Adopt header/footer + first-page variants (null clears the zone); end any edit.
|
||||
hfActive = null;
|
||||
headerDoc = result.header;
|
||||
@@ -513,7 +516,7 @@
|
||||
exportMenuOpen = false;
|
||||
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
|
||||
try {
|
||||
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat);
|
||||
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet());
|
||||
fileHandle = await saveOdt(bytes, suggestedFilename(json), fileHandle);
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name === 'AbortError') return;
|
||||
@@ -529,7 +532,7 @@
|
||||
exportMenuOpen = false;
|
||||
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
|
||||
try {
|
||||
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat);
|
||||
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet());
|
||||
fileHandle = await saveAsOdt(bytes, suggestedFilename(json));
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name === 'AbortError') return;
|
||||
|
||||
+242
-29
@@ -4,6 +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 { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { BORDER_SIDES, parseBorderAttr } from '../editor/extensions/tableCellBorders';
|
||||
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
|
||||
@@ -105,6 +106,11 @@ const DTF = '';
|
||||
// applyHfPostProcess rewrites it to an as-char <draw:frame>. U+E00B.
|
||||
const HFIMG = '';
|
||||
|
||||
// Sentinel wrapping the named paragraph style of a block whose style isn't the ODF
|
||||
// default for its node type (STY<name>STY), emitted as plain run text so it rides the
|
||||
// odf-kit path; applyParagraphStyles points the block at that style. U+E00D.
|
||||
const STY = '';
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
@@ -142,8 +148,55 @@ export const HEADING_FONT = 'Arial';
|
||||
export const MAX_HEADING_LEVEL = HEADING_STYLE_OVERRIDES.length;
|
||||
export const HEADING_LEVELS = HEADING_STYLE_OVERRIDES.map((_, i) => i + 1);
|
||||
|
||||
// ODF encodes spaces in style names as _20_ ("Heading 1" → "Heading_20_1").
|
||||
export function odfStyleName(name: string): string {
|
||||
return name.replace(/ /g, '_20_');
|
||||
}
|
||||
|
||||
// The style name a block carries: its own, else the node type's default. Mirrors
|
||||
// blockStyleName in editor/extensions/paragraphStyle.ts (kept local: this module is
|
||||
// framework-free).
|
||||
function styleOf(node: TiptapNode): string {
|
||||
const own = node.attrs?.styleName;
|
||||
if (typeof own === 'string' && own) return own;
|
||||
return isHeadingNode(node) ? `Heading ${(node.attrs?.level as number) ?? 1}` : DEFAULT_STYLE;
|
||||
}
|
||||
|
||||
// Headings reach the handler renamed to CUST_H (injectCustomTypes).
|
||||
function isHeadingNode(node: TiptapNode): boolean {
|
||||
return node.type === 'heading' || node.type === CUST_H;
|
||||
}
|
||||
|
||||
// Styles to define in the file: every built-in (Word/LibreOffice always define their
|
||||
// standard styles) plus the user styles the document actually references, with their
|
||||
// parent chains.
|
||||
function usedStyleNames(doc: TiptapNode, sheet: StyleSheet): Set<string> {
|
||||
const used = new Set<string>();
|
||||
const addChain = (name: string) => {
|
||||
let cur: string | null | undefined = name;
|
||||
while (cur && sheet.paragraph[cur] && !used.has(cur)) {
|
||||
used.add(cur);
|
||||
cur = sheet.paragraph[cur].parent;
|
||||
}
|
||||
};
|
||||
for (const style of Object.values(sheet.paragraph)) if (style.builtin) addChain(style.name);
|
||||
const walk = (node: TiptapNode) => {
|
||||
if (node.type === 'paragraph' || node.type === 'heading') addChain(styleOf(node));
|
||||
node.content?.forEach(walk);
|
||||
};
|
||||
walk(doc);
|
||||
return used;
|
||||
}
|
||||
|
||||
// What odf-kit itself puts on the block, so only a differing style needs a sentinel.
|
||||
function odfDefaultStyleOf(node: TiptapNode): string {
|
||||
return isHeadingNode(node) ? `Heading ${(node.attrs?.level as number) ?? 1}` : DEFAULT_STYLE;
|
||||
}
|
||||
|
||||
function hasCustomAttrs(attrs: TiptapNode['attrs']): boolean {
|
||||
if (!attrs) return false;
|
||||
// A named style is emitted via the STY sentinel, which needs the applyRuns path.
|
||||
if (typeof attrs.styleName === 'string' && attrs.styleName) return true;
|
||||
if (attrs.lineHeight) return true;
|
||||
if (attrs.spaceBefore != null) return true;
|
||||
if (attrs.spaceAfter != null) return true;
|
||||
@@ -898,6 +951,181 @@ function cloneStyleWithParaProps(def: string, newName: string, props: string): s
|
||||
return s.replace('</style:style>', `<style:paragraph-properties ${props}/></style:style>`);
|
||||
}
|
||||
|
||||
// ---- named paragraph styles (styles/styleSheet.ts) --------------------------------
|
||||
|
||||
// Set or replace attributes on an element's opening tag.
|
||||
function setTagAttrs(tag: string, attrs: Record<string, string>): string {
|
||||
let out = tag;
|
||||
for (const [name, value] of Object.entries(attrs)) {
|
||||
const re = new RegExp(`\\s${name}="[^"]*"`);
|
||||
out = re.test(out)
|
||||
? out.replace(re, ` ${name}="${value}"`)
|
||||
: out.replace(/(\/?>)$/, ` ${name}="${value}"$1`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Merge attributes into a style's <style:paragraph-properties>/<style:text-properties>,
|
||||
// keeping whatever the producer already put there (language, keep-with-next, …).
|
||||
function upsertProps(block: string, kind: 'paragraph' | 'text', attrs: Record<string, string>): string {
|
||||
if (!Object.keys(attrs).length) return block;
|
||||
const el = `style:${kind}-properties`;
|
||||
const open = new RegExp(`<${el}\\b[^>]*>`);
|
||||
const m = open.exec(block);
|
||||
if (m) return block.replace(open, setTagAttrs(m[0], attrs));
|
||||
const props = setTagAttrs(`<${el}/>`, attrs);
|
||||
// paragraph-properties precedes text-properties in ODF.
|
||||
if (kind === 'paragraph' && block.includes('<style:text-properties'))
|
||||
return block.replace('<style:text-properties', `${props}<style:text-properties`);
|
||||
if (/^<style:style\b[^>]*\/>$/.test(block.trim()))
|
||||
return block.trim().replace(/\/>$/, `>${props}</style:style>`);
|
||||
return block.replace('</style:style>', `${props}</style:style>`);
|
||||
}
|
||||
|
||||
// A style's OWN properties as ODF attributes — the chain stays a chain in the file,
|
||||
// so only what the style itself declares is written.
|
||||
function ownStyleAttrs(style: { para: Record<string, unknown>; text: Record<string, unknown> }) {
|
||||
const p = style.para as Record<string, string | number | undefined>;
|
||||
const t = style.text as Record<string, string | number | boolean | undefined>;
|
||||
const para: Record<string, string> = {};
|
||||
if (p.textAlign) para['fo:text-align'] = String(p.textAlign);
|
||||
if (p.lineHeight != null) para['fo:line-height'] = normalizeLineHeight(p.lineHeight as string | number);
|
||||
if (p.spaceBefore != null) para['fo:margin-top'] = `${p.spaceBefore}pt`;
|
||||
if (p.spaceAfter != null) para['fo:margin-bottom'] = `${p.spaceAfter}pt`;
|
||||
if (p.indent != null) para['fo:margin-left'] = `${p.indent}cm`;
|
||||
if (p.backgroundColor) para['fo:background-color'] = String(p.backgroundColor);
|
||||
for (const [key, side] of [['borderTop', 'top'], ['borderRight', 'right'], ['borderBottom', 'bottom'], ['borderLeft', 'left']] as const) {
|
||||
const v = p[key];
|
||||
if (v && v !== 'none') para[`fo:border-${side}`] = String(v);
|
||||
}
|
||||
const text: Record<string, string> = {};
|
||||
if (t.fontFamily) {
|
||||
// Liberation Serif is the on-screen name; the file declares its metric twin.
|
||||
const font = t.fontFamily === ODFKIT_DEFAULT_FONT ? EXPORT_FONT : String(t.fontFamily);
|
||||
text['style:font-name'] = font;
|
||||
text['style:font-name-asian'] = font;
|
||||
text['style:font-name-complex'] = font;
|
||||
}
|
||||
if (t.fontSizePt != null) {
|
||||
const size = `${t.fontSizePt}pt`;
|
||||
text['fo:font-size'] = size;
|
||||
text['style:font-size-asian'] = size;
|
||||
text['style:font-size-complex'] = size;
|
||||
}
|
||||
if (t.bold != null) text['fo:font-weight'] = t.bold ? 'bold' : 'normal';
|
||||
if (t.italic != null) text['fo:font-style'] = t.italic ? 'italic' : 'normal';
|
||||
if (t.underline) text['style:text-underline-style'] = 'solid';
|
||||
if (t.strike) text['style:text-line-through-style'] = 'solid';
|
||||
if (t.color) text['fo:color'] = String(t.color);
|
||||
return { para, text };
|
||||
}
|
||||
|
||||
// Attributes the style model owns: dropped from a producer's block before the style's
|
||||
// own values go in, so anything the style leaves open really comes from its parent.
|
||||
const MANAGED_STYLE_ATTRS = [
|
||||
'fo:margin-top', 'fo:margin-bottom', 'fo:margin-left', 'fo:text-align', 'fo:line-height',
|
||||
'fo:background-color', 'fo:border-top', 'fo:border-right', 'fo:border-bottom', 'fo:border-left',
|
||||
'fo:font-size', 'style:font-size-asian', 'style:font-size-complex',
|
||||
'fo:font-weight', 'style:font-weight-asian', 'style:font-weight-complex',
|
||||
'fo:font-style', 'style:font-style-asian', 'style:font-style-complex',
|
||||
'style:text-underline-style', 'style:text-line-through-style', 'fo:color',
|
||||
'style:font-name', 'style:font-name-asian', 'style:font-name-complex',
|
||||
];
|
||||
|
||||
function stripManagedProps(block: string): string {
|
||||
let out = block;
|
||||
for (const attr of MANAGED_STYLE_ATTRS) out = out.replace(new RegExp(`\\s${attr}="[^"]*"`, 'g'), '');
|
||||
return out;
|
||||
}
|
||||
|
||||
// Write the document's named paragraph styles into styles.xml: merge into the blocks
|
||||
// odf-kit already emits (Standard, Heading, Heading_20_N), append the rest. The parent
|
||||
// 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[] = [];
|
||||
for (const style of Object.values(sheet.paragraph)) {
|
||||
if (!used.has(style.name)) continue;
|
||||
const odfName = odfStyleName(style.name);
|
||||
const { para, text } = ownStyleAttrs(style);
|
||||
const existing = findAutoStyle(styles, odfName);
|
||||
if (existing) {
|
||||
let block = stripManagedProps(existing);
|
||||
if (style.parent) block = block.replace(/^<style:style\b[^>]*?(\/?)>/, (tag) =>
|
||||
setTagAttrs(tag, { 'style:parent-style-name': odfStyleName(style.parent!) }));
|
||||
block = upsertProps(upsertProps(block, 'paragraph', para), 'text', text);
|
||||
styles = styles.replace(existing, block);
|
||||
continue;
|
||||
}
|
||||
const attrs: Record<string, string> = {
|
||||
'style:name': odfName, 'style:family': 'paragraph', 'style:display-name': style.name,
|
||||
};
|
||||
if (style.parent) attrs['style:parent-style-name'] = odfStyleName(style.parent);
|
||||
if (style.next) attrs['style:next-style-name'] = odfStyleName(style.next);
|
||||
let block = `${setTagAttrs('<style:style/>', attrs).replace('/>', '>')}</style:style>`;
|
||||
block = upsertProps(upsertProps(block, 'paragraph', para), 'text', text);
|
||||
added.push(block);
|
||||
}
|
||||
return added.length ? styles.replace('</office:styles>', `${added.join('')}</office:styles>`) : styles;
|
||||
}
|
||||
|
||||
// Resolve STY sentinels: point each marked block at its named style — directly when it
|
||||
// carries no direct formatting, else via a clone of its automatic style whose parent
|
||||
// becomes the named one (so hard formatting keeps overriding the style, as in LO).
|
||||
function applyParagraphStyles(odtBytes: Uint8Array): Uint8Array {
|
||||
const files = unzipSync(odtBytes);
|
||||
const contentBytes = files['content.xml'];
|
||||
if (!contentBytes) return odtBytes;
|
||||
|
||||
let content = strFromU8(contentBytes);
|
||||
if (!content.includes(STY)) 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; // no direct formatting: reference the style itself
|
||||
const key = `${source}|${odfName}`;
|
||||
const existing = nameByKey.get(key);
|
||||
if (existing) return existing;
|
||||
const name = `PS${++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 styRe = new RegExp(`${STY}([^${STY}]*)${STY}`);
|
||||
content = content.replace(
|
||||
new RegExp(`<text:(p|h)\\b([^>]*)>([\\s\\S]*?)</text:\\1>`, 'g'),
|
||||
(m, tag: string, attrs: string, inner: string) => {
|
||||
const sm = styRe.exec(inner);
|
||||
if (!sm) return m;
|
||||
const cleaned = inner.replace(styRe, '');
|
||||
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:${tag}${newAttrs}>${cleaned}</text:${tag}>`;
|
||||
},
|
||||
);
|
||||
content = content.replace(new RegExp(`${STY}[^${STY}]*${STY}`, 'g'), '');
|
||||
|
||||
if (minted.length) {
|
||||
content = content.replace('</office:automatic-styles>', `${minted.join('')}</office:automatic-styles>`);
|
||||
}
|
||||
|
||||
files['content.xml'] = strToU8(content);
|
||||
return rezipOdt(files);
|
||||
}
|
||||
|
||||
// Resolve PBX sentinels: per (source style, box spec) mint a paragraph style cloning the
|
||||
// source plus fo:background-color/fo:border-*, reassign the block, and strip the sentinel.
|
||||
// Mirrors applyEmptyLineFontSizes; runs after it so an empty line's FSZ is already resolved.
|
||||
@@ -1487,7 +1715,7 @@ function rezipOdt(files: Record<string, Uint8Array>): Uint8Array {
|
||||
// Rewrite styles.xml to match the editor's preview: default font Liberation Serif →
|
||||
// Times New Roman (metric-identical; see EXPORT_FONT), and Heading_20_1/2/3 sizes &
|
||||
// margins → the editor's values (odf-kit's defaults are larger; HEADING_STYLE_OVERRIDES).
|
||||
function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation): Uint8Array {
|
||||
function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set<string>): Uint8Array {
|
||||
const files = unzipSync(odtBytes);
|
||||
const stylesBytes = files['styles.xml'];
|
||||
if (!stylesBytes) return odtBytes;
|
||||
@@ -1529,35 +1757,15 @@ function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; countr
|
||||
`$1fo:margin-bottom="0cm"`,
|
||||
);
|
||||
|
||||
// odf-kit's Heading parent carries only weight, so its font would be inherited from
|
||||
// Standard (= the serif body font). Declare the sans heading font on it; every
|
||||
// Heading_20_N inherits it, matching the editor's --font-heading.
|
||||
// ODF requires a font-face declaration for every referenced font name.
|
||||
styles = styles.replace(
|
||||
'</office:font-face-decls>',
|
||||
`<style:font-face style:name="${HEADING_FONT}" svg:font-family="${HEADING_FONT}" style:font-family-generic="swiss" style:font-pitch="variable"/></office:font-face-decls>`,
|
||||
);
|
||||
|
||||
styles = styles.replace(/<style:style style:name="Heading"[\s\S]*?<\/style:style>/, (block) =>
|
||||
block.replace(
|
||||
'<style:text-properties',
|
||||
`<style:text-properties style:font-name="${HEADING_FONT}" style:font-name-asian="${HEADING_FONT}" style:font-name-complex="${HEADING_FONT}"`,
|
||||
),
|
||||
);
|
||||
|
||||
// Scope each rewrite to its own <style:style …>…</style:style> block so the
|
||||
// font-size/margin replacements never bleed across heading levels.
|
||||
for (const { name, fontSize, marginTop, marginBottom } of HEADING_STYLE_OVERRIDES) {
|
||||
const re = new RegExp(`<style:style style:name="${name}"[\\s\\S]*?</style:style>`);
|
||||
styles = styles.replace(re, (block) =>
|
||||
block
|
||||
.replace(/fo:font-size="[^"]*"/g, `fo:font-size="${fontSize}"`)
|
||||
.replace(/style:font-size-asian="[^"]*"/g, `style:font-size-asian="${fontSize}"`)
|
||||
.replace(/style:font-size-complex="[^"]*"/g, `style:font-size-complex="${fontSize}"`)
|
||||
.replace(/fo:margin-top="[^"]*"/g, `fo:margin-top="${marginTop}"`)
|
||||
.replace(/fo:margin-bottom="[^"]*"/g, `fo:margin-bottom="${marginBottom}"`),
|
||||
);
|
||||
}
|
||||
// The document's named paragraph styles (Standard, Heading, Heading_20_N, Title, …)
|
||||
// with their parent chain: merged into odf-kit's own blocks, appended when new.
|
||||
styles = applyNamedStyles(styles, sheet, used);
|
||||
|
||||
files['styles.xml'] = strToU8(styles);
|
||||
return rezipOdt(files);
|
||||
@@ -2237,7 +2445,7 @@ export type HfExport = {
|
||||
};
|
||||
|
||||
// The full document → .odt pipeline, DOM-free; returns the .odt bytes.
|
||||
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4'): Promise<Uint8Array> {
|
||||
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4', styles: StyleSheet = builtinStyleSheet()): Promise<Uint8Array> {
|
||||
// Collect embedded images and swap them for IMG sentinels before serialization;
|
||||
// applyImages resolves the sentinels and writes the Pictures/ + manifest entries.
|
||||
// Text boxes and columns sections are hoisted after replacePageBreaks (so PGB never
|
||||
@@ -2354,14 +2562,18 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
// which runs earlier, still matches it right after the opening tag.
|
||||
const spec = paraBoxSpec(node.attrs);
|
||||
const pbx = spec ? `${PBX}${spec}${PBX}` : '';
|
||||
const withPbx = (p: ParagraphBuilder) => { if (pbx) p.addText(pbx); applyRuns(p, content); };
|
||||
// Named style, unless it is the one odf-kit puts on this node type anyway.
|
||||
const styleName = styleOf(node);
|
||||
const sty = styleName !== odfDefaultStyleOf(node) ? `${STY}${styleName}${STY}` : '';
|
||||
const marks = sty + pbx;
|
||||
const withPbx = (p: ParagraphBuilder) => { if (marks) p.addText(marks); applyRuns(p, content); };
|
||||
|
||||
if (node.type === CUST_P) {
|
||||
if (content.length === 0) doc.addParagraph(emptyFs + pbx, opts);
|
||||
if (content.length === 0) doc.addParagraph(emptyFs + marks, opts);
|
||||
else doc.addParagraph(withPbx, opts);
|
||||
} else if (node.type === CUST_H) {
|
||||
const level = (node.attrs?.level as number) ?? 1;
|
||||
if (content.length === 0) doc.addHeading(emptyFs + pbx, level, opts);
|
||||
if (content.length === 0) doc.addHeading(emptyFs + marks, level, opts);
|
||||
else doc.addHeading(withPbx, level, opts);
|
||||
}
|
||||
},
|
||||
@@ -2416,7 +2628,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
const withPageBreaks = applyPageBreaks(withToc);
|
||||
const withEmptyFontSizes = applyEmptyLineFontSizes(withPageBreaks);
|
||||
const withParaBoxes = applyParagraphBoxes(withEmptyFontSizes);
|
||||
const withStyles = rewriteStylesXml(withParaBoxes, language ?? null, pageFormat, orientation);
|
||||
const withNamedStyles = 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { unzipSync, strFromU8 } from 'fflate';
|
||||
import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, wVal, W, R, WP, A, WPS, MC, VML, PKG_REL, type RunProps, type ParaSpacing } from './docxStyles';
|
||||
import { lengthToPt } from './styleResolver';
|
||||
import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, normalizeColor } from '../export/odt';
|
||||
import { builtinStyleSheet } from '../styles/styleSheet';
|
||||
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
|
||||
import { bulletCharAttr, bulletCharFromDocx } from '../utils/bulletListTypes';
|
||||
@@ -157,6 +158,9 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
|
||||
|
||||
return {
|
||||
content: { type: 'doc', content: blocks },
|
||||
// DOCX styles still import as direct formatting (the ODF path leads); the document
|
||||
// keeps the editor's built-in registry.
|
||||
styles: builtinStyleSheet(),
|
||||
margins: sect.margins,
|
||||
orientation: sect.orientation,
|
||||
format: sect.format,
|
||||
|
||||
+215
-30
@@ -1,6 +1,7 @@
|
||||
import { unzipSync, strFromU8 } from 'fflate';
|
||||
import { StyleResolver, NS, lengthToPt, lengthToCm, layerTextProps, type PropMap } from './styleResolver';
|
||||
import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, normalizeColor } from '../export/odt';
|
||||
import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet';
|
||||
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
|
||||
import { bulletCharAttr, bulletCharFromOdf } from '../utils/bulletListTypes';
|
||||
@@ -52,6 +53,9 @@ export interface OdtImportResult {
|
||||
language: DocumentLanguage | null;
|
||||
// Fonts embedded in the package, to register via FontFace so they render.
|
||||
fonts: EmbeddedFont[];
|
||||
// The document's named paragraph styles: the file's own (only those it uses, plus
|
||||
// their parent chains) merged over the editor's built-ins.
|
||||
styles: StyleSheet;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
@@ -65,6 +69,10 @@ type BlockKind = 'body' | 'list' | 'cell';
|
||||
// they are block nodes, so convertBlocks flushes them after the anchor paragraph.
|
||||
type Ctx = {
|
||||
resolver: StyleResolver;
|
||||
// ODF style name → display name, and the names blocks actually reference: the
|
||||
// document's style registry is built from these (only used styles are kept).
|
||||
styleNames: Map<string, string>;
|
||||
usedStyles: Set<string>;
|
||||
warnings: Set<string>;
|
||||
files: Record<string, Uint8Array>;
|
||||
imageCache: Map<string, string>;
|
||||
@@ -335,7 +343,9 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
|
||||
const body = contentDoc.getElementsByTagNameNS(NS.office, 'text')[0];
|
||||
if (!body) throw new Error('Not a text document (no office:text body).');
|
||||
|
||||
const ctx: Ctx = { resolver, warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [] };
|
||||
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: [] };
|
||||
let blocks = convertBlocks(Array.from(body.children), ctx, 'body');
|
||||
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
|
||||
|
||||
@@ -401,6 +411,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
|
||||
footerDistanceCm: hasFooter ? edge?.bottom ?? null : null,
|
||||
language,
|
||||
fonts,
|
||||
styles: collectStyleSheet(resolver, ctx),
|
||||
warnings: [...warnings],
|
||||
};
|
||||
}
|
||||
@@ -421,7 +432,7 @@ function convertHfZone(zoneEl: Element, ctx: Ctx): HfDoc {
|
||||
if (textAlign === null) textAlign = ''; // only the first paragraph decides
|
||||
}
|
||||
boxMaps.push(paraBoxAttrs(ctx.resolver.paraProps(styleName)));
|
||||
inline.push(...convertInline(p, ctx, ctx.resolver.paraTextProps(styleName), null, true));
|
||||
inline.push(...convertInline(p, ctx, ctx.resolver.paraTextProps(styleName), blockDefaults(ctx.resolver, null, null, false), true));
|
||||
};
|
||||
|
||||
for (const child of Array.from(zoneEl.children)) {
|
||||
@@ -617,6 +628,170 @@ function tocEntryTextAndPage(p: Element): { text: string; page: string } {
|
||||
return { text: before.trim(), page: after.trim() };
|
||||
}
|
||||
|
||||
// What a block's named style already gives it — the yardstick for "is this direct
|
||||
// formatting?". Without a style in the file, the editor's own defaults stand in.
|
||||
type BlockDefaults = {
|
||||
fontSizePt: number;
|
||||
marginTopPt: number;
|
||||
marginBottomPt: number;
|
||||
indentPt: number;
|
||||
boldByDefault: boolean;
|
||||
fonts: Set<string>;
|
||||
color: string;
|
||||
// Marks the style already provides. A run that opts *out* of one can't be expressed
|
||||
// (only bold has fontWeight:'normal'), so it keeps the style's rendering.
|
||||
// ponytail: per-mark "off" overrides need mark attrs like fontWeight's; add if files need it.
|
||||
italic: boolean;
|
||||
underline: boolean;
|
||||
strike: boolean;
|
||||
};
|
||||
|
||||
// Metric twins: the on-screen font and the name we declare in files mean the same thing.
|
||||
const FONT_TWINS: Record<string, string[]> = {
|
||||
'times new roman': ['liberation serif'],
|
||||
'liberation serif': ['times new roman'],
|
||||
arial: ['liberation sans'],
|
||||
'liberation sans': ['arial'],
|
||||
};
|
||||
|
||||
function blockDefaults(resolver: StyleResolver, named: string | null, headingLevel: number | null, boldByDefault: boolean): BlockDefaults {
|
||||
const hdef = headingLevel != null ? HEADING_DEFAULTS[headingLevel - 1] : null;
|
||||
const fallback: BlockDefaults = {
|
||||
fontSizePt: hdef ? hdef.fontSizePt : BODY_FONT_SIZE_PT,
|
||||
marginTopPt: hdef ? hdef.marginTopPt : 0,
|
||||
marginBottomPt: hdef ? hdef.marginBottomPt : 0,
|
||||
indentPt: 0,
|
||||
boldByDefault: headingLevel != null || boldByDefault,
|
||||
fonts: new Set(headingLevel != null ? DEFAULT_HEADING_FONTS : DEFAULT_FONTS),
|
||||
color: '#000000',
|
||||
italic: false,
|
||||
underline: false,
|
||||
strike: false,
|
||||
};
|
||||
if (!named) return fallback;
|
||||
const para = resolver.paraProps(named);
|
||||
const text = resolver.paraTextProps(named);
|
||||
const family = resolver.fontFamilyOf(text)?.toLowerCase();
|
||||
const fonts = new Set(fallback.fonts);
|
||||
if (family) {
|
||||
fonts.add(family);
|
||||
for (const twin of FONT_TWINS[family] ?? []) fonts.add(twin);
|
||||
}
|
||||
const weight = text['fo:font-weight'];
|
||||
const fontStyle = text['fo:font-style'];
|
||||
return {
|
||||
fontSizePt: lengthToPt(text['fo:font-size']) ?? fallback.fontSizePt,
|
||||
marginTopPt: lengthToPt(para['fo:margin-top']) ?? 0,
|
||||
marginBottomPt: lengthToPt(para['fo:margin-bottom']) ?? 0,
|
||||
indentPt: lengthToPt(para['fo:margin-left']) ?? 0,
|
||||
boldByDefault: weight ? weight === 'bold' || parseInt(weight, 10) >= 600 : fallback.boldByDefault,
|
||||
fonts,
|
||||
color: (text['fo:color'] && normalizeColor(text['fo:color'])) || fallback.color,
|
||||
italic: fontStyle === 'italic' || fontStyle === 'oblique',
|
||||
underline: !!text['style:text-underline-style'] && text['style:text-underline-style'] !== 'none',
|
||||
strike: !!text['style:text-line-through-style'] && text['style:text-line-through-style'] !== 'none',
|
||||
};
|
||||
}
|
||||
|
||||
// ODF encodes spaces as _20_; a style:display-name (when present) is authoritative.
|
||||
function displayStyleName(odfName: string, display?: string): string {
|
||||
return display || odfName.replace(/_20_/g, ' ');
|
||||
}
|
||||
|
||||
function paraPropsFromOdf(props: PropMap): ParaProps {
|
||||
const out: ParaProps = {};
|
||||
const ta = props['fo:text-align'];
|
||||
if (ta === 'center' || ta === 'justify') out.textAlign = ta;
|
||||
else if (ta === 'right' || ta === 'end') out.textAlign = 'right';
|
||||
else if (ta === 'left' || ta === 'start') out.textAlign = 'left';
|
||||
const mt = lengthToPt(props['fo:margin-top']);
|
||||
const mb = lengthToPt(props['fo:margin-bottom']);
|
||||
const ml = lengthToCm(props['fo:margin-left']);
|
||||
if (mt != null) out.spaceBefore = snapPt(mt);
|
||||
if (mb != null) out.spaceAfter = snapPt(mb);
|
||||
if (ml != null) out.indent = Math.round(ml * 100) / 100;
|
||||
const lh = props['fo:line-height'];
|
||||
if (lh && lh.endsWith('%')) {
|
||||
const mult = parseFloat(lh) / 100 / LINE_HEIGHT_RATIO;
|
||||
if (Number.isFinite(mult)) out.lineHeight = String(Math.round(mult * 100) / 100);
|
||||
}
|
||||
const bg = props['fo:background-color'];
|
||||
if (bg && bg !== 'transparent') out.backgroundColor = normalizeColor(bg) ?? undefined;
|
||||
for (const [key, side] of [['borderTop', 'top'], ['borderRight', 'right'], ['borderBottom', 'bottom'], ['borderLeft', 'left']] as const) {
|
||||
const v = props[`fo:border-${side}`] ?? props['fo:border'];
|
||||
if (v && v !== 'none') out[key] = borderAttrFromOdf(v) ?? undefined;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function textPropsFromOdf(props: PropMap, resolver: StyleResolver): TextProps {
|
||||
const out: TextProps = {};
|
||||
const family = resolver.fontFamilyOf(props);
|
||||
// Our own export declares the metric twin; keep the registry on the on-screen name
|
||||
// so an export→import loop doesn't drift.
|
||||
if (family) out.fontFamily = family === 'Times New Roman' ? 'Liberation Serif' : family;
|
||||
const size = lengthToPt(props['fo:font-size']);
|
||||
if (size != null) out.fontSizePt = Math.round(size * 10) / 10;
|
||||
const weight = props['fo:font-weight'];
|
||||
if (weight) out.bold = weight === 'bold' || parseInt(weight, 10) >= 600;
|
||||
const style = props['fo:font-style'];
|
||||
if (style) out.italic = style === 'italic' || style === 'oblique';
|
||||
const ul = props['style:text-underline-style'];
|
||||
if (ul) out.underline = ul !== 'none';
|
||||
const lt = props['style:text-line-through-style'];
|
||||
if (lt) out.strike = lt !== 'none';
|
||||
const color = props['fo:color'] ? normalizeColor(props['fo:color']) : undefined;
|
||||
if (color) out.color = color;
|
||||
return out;
|
||||
}
|
||||
|
||||
// What a style declares itself: its resolved props minus the parent's.
|
||||
function ownProps<T extends object>(resolved: T, parent: T): T {
|
||||
const out = {} as T;
|
||||
for (const key of Object.keys(resolved) as (keyof T)[]) {
|
||||
if (resolved[key] !== undefined && resolved[key] !== parent[key]) out[key] = resolved[key];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The document's style registry: the editor's built-ins, with the file's own definitions
|
||||
// merged over them — only the styles blocks actually reference, plus their parent chains.
|
||||
function collectStyleSheet(resolver: StyleResolver, ctx: Ctx): StyleSheet {
|
||||
const defs = resolver.namedParagraphStyles();
|
||||
const sheet = builtinStyleSheet();
|
||||
const keep = new Set<string>();
|
||||
for (const name of ctx.usedStyles) {
|
||||
let cur: string | null = name;
|
||||
const seen = new Set<string>();
|
||||
while (cur && defs.has(cur) && !seen.has(cur)) {
|
||||
seen.add(cur);
|
||||
keep.add(cur);
|
||||
cur = defs.get(cur)!.parent;
|
||||
}
|
||||
}
|
||||
for (const odfName of keep) {
|
||||
const def = defs.get(odfName)!;
|
||||
const name = displayStyleName(odfName, def.display);
|
||||
const parent = def.parent ? displayStyleName(def.parent, defs.get(def.parent)?.display) : null;
|
||||
const builtin = sheet.paragraph[name];
|
||||
// Own props = resolved minus the parent's resolved values. Going through the
|
||||
// resolver keeps relative sizes ("130%") and values a file repeats from its parent
|
||||
// from landing here raw.
|
||||
const style: Style = {
|
||||
name,
|
||||
parent: parent && parent !== name ? parent : builtin?.parent ?? null,
|
||||
next: builtin?.next ?? null,
|
||||
builtin: builtin?.builtin,
|
||||
para: ownProps(paraPropsFromOdf(resolver.paraProps(odfName)), paraPropsFromOdf(def.parent ? resolver.paraProps(def.parent) : {})),
|
||||
text: ownProps(textPropsFromOdf(resolver.paraTextProps(odfName), resolver), textPropsFromOdf(def.parent ? resolver.paraTextProps(def.parent) : {}, resolver)),
|
||||
};
|
||||
const level = /^Heading (\d)$/.exec(name);
|
||||
if (level) style.outlineLevel = Number(level[1]);
|
||||
sheet.paragraph[name] = style;
|
||||
}
|
||||
return sheet;
|
||||
}
|
||||
|
||||
function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault = false): Node {
|
||||
const { resolver } = ctx;
|
||||
const styleName = el.getAttributeNS(NS.text, 'style-name');
|
||||
@@ -630,19 +805,30 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault =
|
||||
level = Math.min(MAX_HEADING_LEVEL, Math.max(1, Number.isFinite(raw) ? raw : 1));
|
||||
}
|
||||
|
||||
const attrs = blockAttrs(paraProps, baseTextProps, isHeading ? level : null, kind);
|
||||
const content = convertInline(el, ctx, baseTextProps, isHeading ? level : null, false, boldByDefault);
|
||||
// The file's own named style for this block (an automatic style is direct formatting
|
||||
// layered on top of it); everything it already provides is not direct formatting.
|
||||
const named = resolver.namedAncestor(styleName);
|
||||
const defaults = blockDefaults(resolver, named, isHeading ? level : null, boldByDefault);
|
||||
|
||||
const attrs = blockAttrs(paraProps, baseTextProps, defaults, kind);
|
||||
const content = convertInline(el, ctx, baseTextProps, defaults, false);
|
||||
|
||||
// An empty line has no runs, so its height comes from the paragraph style's own
|
||||
// font size; carry it as a block attr (mirrors the docx paragraph-mark size).
|
||||
if (content.length === 0) {
|
||||
const sizePt = lengthToPt(baseTextProps['fo:font-size']);
|
||||
const defSize = isHeading ? HEADING_DEFAULTS[level - 1].fontSizePt : BODY_FONT_SIZE_PT;
|
||||
if (sizePt != null && Math.abs(sizePt - defSize) > 0.05) attrs.fontSize = formatPt(sizePt);
|
||||
if (sizePt != null && Math.abs(sizePt - defaults.fontSizePt) > 0.05) attrs.fontSize = formatPt(sizePt);
|
||||
}
|
||||
|
||||
const node: Node = { type: isHeading ? 'heading' : 'paragraph' };
|
||||
if (isHeading) attrs.level = level;
|
||||
// Only top-level blocks carry a style: list items and cells reference the producer's
|
||||
// own plumbing styles (List_20_Bullet, Table_20_Contents), which are not user styles.
|
||||
if (named && kind === 'body') {
|
||||
const display = ctx.styleNames.get(named) ?? named;
|
||||
ctx.usedStyles.add(named);
|
||||
if (display !== (isHeading ? `Heading ${level}` : DEFAULT_STYLE)) attrs.styleName = display;
|
||||
}
|
||||
if (Object.keys(attrs).length) node.attrs = attrs;
|
||||
if (content.length) node.content = content;
|
||||
return node;
|
||||
@@ -650,9 +836,8 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault =
|
||||
|
||||
// textAlign / lineHeight / spaceBefore / spaceAfter from resolved paragraph
|
||||
// props, suppressing values that match the editor's defaults for this context.
|
||||
function blockAttrs(paraProps: PropMap, textProps: PropMap, headingLevel: number | null, kind: BlockKind): Record<string, unknown> {
|
||||
function blockAttrs(paraProps: PropMap, textProps: PropMap, defaults: BlockDefaults, kind: BlockKind): Record<string, unknown> {
|
||||
const attrs: Record<string, unknown> = {};
|
||||
const hdef = headingLevel != null ? HEADING_DEFAULTS[headingLevel - 1] : null;
|
||||
|
||||
const ta = paraProps['fo:text-align'];
|
||||
if (ta === 'center' || ta === 'justify') attrs.textAlign = ta;
|
||||
@@ -667,7 +852,7 @@ function blockAttrs(paraProps: PropMap, textProps: PropMap, headingLevel: number
|
||||
} else {
|
||||
// Fixed line height: best-effort multiplier against the resolved font size.
|
||||
const pt = lengthToPt(lh);
|
||||
const fontPt = lengthToPt(textProps['fo:font-size']) ?? hdef?.fontSizePt ?? BODY_FONT_SIZE_PT;
|
||||
const fontPt = lengthToPt(textProps['fo:font-size']) ?? defaults.fontSizePt;
|
||||
if (pt != null && fontPt > 0) mult = pt / (fontPt * LINE_HEIGHT_RATIO);
|
||||
}
|
||||
if (mult != null) {
|
||||
@@ -676,8 +861,8 @@ function blockAttrs(paraProps: PropMap, textProps: PropMap, headingLevel: number
|
||||
}
|
||||
}
|
||||
|
||||
const defTop = hdef ? hdef.marginTopPt : 0;
|
||||
const defBottom = hdef ? hdef.marginBottomPt : 0;
|
||||
const defTop = defaults.marginTopPt;
|
||||
const defBottom = defaults.marginBottomPt;
|
||||
// An unspecified fo:margin is ODF's default of 0, which is also the editor's
|
||||
// paragraph default — so it is suppressed, and a file that does declare spacing
|
||||
// (e.g. LO's 0.212cm Text Body) keeps it as an explicit value.
|
||||
@@ -687,10 +872,11 @@ function blockAttrs(paraProps: PropMap, textProps: PropMap, headingLevel: number
|
||||
if (Math.abs(mb - defBottom) > EPS_PT) attrs.spaceAfter = snapPt(mb);
|
||||
|
||||
// Left indent → fo:margin-left (cm). Skip lists: their indent lives in the
|
||||
// list-style level properties, not paraProps. Default 0 is suppressed.
|
||||
// list-style level properties, not paraProps. What the style already indents is not
|
||||
// direct formatting.
|
||||
if (kind !== 'list') {
|
||||
const ml = lengthToPt(paraProps['fo:margin-left']);
|
||||
if (ml != null && ml > EPS_PT) attrs.indent = Math.round((ml / 72) * 2.54 * 100) / 100;
|
||||
const ml = lengthToPt(paraProps['fo:margin-left']) ?? 0;
|
||||
if (Math.abs(ml - defaults.indentPt) > EPS_PT) attrs.indent = Math.round((ml / 72) * 2.54 * 100) / 100;
|
||||
}
|
||||
|
||||
// Manual page break (fo:break-before). Honored for top-level blocks only
|
||||
@@ -811,7 +997,7 @@ function numberStyleKind(tokens: Token[]): 'date' | 'time' | null {
|
||||
|
||||
// ---- inline conversion --------------------------------------------------------
|
||||
|
||||
function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel: number | null, hfFields = false, boldByDefault = false): Node[] {
|
||||
function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: BlockDefaults, hfFields = false): Node[] {
|
||||
const out: Node[] = [];
|
||||
|
||||
const pushText = (text: string, props: PropMap, linkHref?: string) => {
|
||||
@@ -824,7 +1010,7 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel
|
||||
clean = clean.replace(/[ \t]*\n[ \t]*/g, ' ');
|
||||
}
|
||||
if (!clean) return;
|
||||
const marks = marksFor(props, ctx.resolver, headingLevel, boldByDefault);
|
||||
const marks = marksFor(props, ctx.resolver, defaults);
|
||||
if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } });
|
||||
const node: Node = { type: 'text', text: clean };
|
||||
if (marks.length) node.marks = marks;
|
||||
@@ -855,7 +1041,7 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel
|
||||
continue;
|
||||
case 'line-break': {
|
||||
// Carry the run's marks so an empty line between two breaks keeps its font size.
|
||||
const marks = marksFor(props, ctx.resolver, headingLevel, boldByDefault);
|
||||
const marks = marksFor(props, ctx.resolver, defaults);
|
||||
const br: Node = { type: 'hardBreak' };
|
||||
if (marks.length) br.marks = marks;
|
||||
out.push(br);
|
||||
@@ -884,7 +1070,7 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel
|
||||
// carries the run's marks so its digits render in the run's font/size.
|
||||
if (hfFields && (e.localName === 'page-number' || e.localName === 'page-count')) {
|
||||
const field: Node = { type: e.localName === 'page-count' ? 'pageCount' : 'pageNumber' };
|
||||
const marks = marksFor(props, ctx.resolver, headingLevel, boldByDefault);
|
||||
const marks = marksFor(props, ctx.resolver, defaults);
|
||||
if (marks.length) field.marks = marks;
|
||||
out.push(field);
|
||||
continue;
|
||||
@@ -898,7 +1084,7 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel
|
||||
// The field is an atom without inline children, so it can't inherit
|
||||
// the surrounding run's font from a sibling span — carry the run's
|
||||
// marks on the node so it renders in the same font/size/etc.
|
||||
const marks = marksFor(props, ctx.resolver, headingLevel, boldByDefault);
|
||||
const marks = marksFor(props, ctx.resolver, defaults);
|
||||
if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } });
|
||||
if (marks.length) field.marks = marks;
|
||||
out.push(field);
|
||||
@@ -952,7 +1138,7 @@ function mergeAdjacentText(nodes: Node[]): Node[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function marksFor(props: PropMap, resolver: StyleResolver, headingLevel: number | null, boldByDefault = false): Mark[] {
|
||||
function marksFor(props: PropMap, resolver: StyleResolver, defaults: BlockDefaults): Mark[] {
|
||||
const marks: Mark[] = [];
|
||||
const textStyle: Record<string, unknown> = {};
|
||||
|
||||
@@ -960,18 +1146,19 @@ function marksFor(props: PropMap, resolver: StyleResolver, headingLevel: number
|
||||
const bold = weight ? weight === 'bold' || parseInt(weight, 10) >= 600 : null;
|
||||
// Headings and header-row cells render bold by default (CSS): only a *non*-bold run
|
||||
// needs a mark (fontWeight:normal), and a bold run needs none.
|
||||
if (headingLevel != null || boldByDefault) {
|
||||
if (defaults.boldByDefault) {
|
||||
if (bold === false) textStyle.fontWeight = 'normal';
|
||||
} else if (bold === true) {
|
||||
marks.push({ type: 'bold' });
|
||||
}
|
||||
|
||||
// Marks the block's named style already renders need no mark of their own.
|
||||
const fs = props['fo:font-style'];
|
||||
if (fs === 'italic' || fs === 'oblique') marks.push({ type: 'italic' });
|
||||
if ((fs === 'italic' || fs === 'oblique') && !defaults.italic) marks.push({ type: 'italic' });
|
||||
const ul = props['style:text-underline-style'];
|
||||
if (ul && ul !== 'none') marks.push({ type: 'underline' });
|
||||
if (ul && ul !== 'none' && !defaults.underline) marks.push({ type: 'underline' });
|
||||
const lt = props['style:text-line-through-style'];
|
||||
if (lt && lt !== 'none') marks.push({ type: 'strike' });
|
||||
if (lt && lt !== 'none' && !defaults.strike) marks.push({ type: 'strike' });
|
||||
|
||||
// "super 58%" / "sub" / bare percentage (positive = raised, negative = lowered).
|
||||
const pos = props['style:text-position'];
|
||||
@@ -992,16 +1179,14 @@ function marksFor(props: PropMap, resolver: StyleResolver, headingLevel: number
|
||||
}
|
||||
|
||||
const color = props['fo:color'] ? normalizeColor(props['fo:color']) : undefined;
|
||||
// Pure black is the theme default; an explicit mark would fight dark/allBlack.
|
||||
if (color && color !== '#000000') textStyle.color = color;
|
||||
// The style's own color (black by default; an explicit black mark would fight dark/allBlack).
|
||||
if (color && color !== defaults.color) textStyle.color = color;
|
||||
|
||||
const sizePt = lengthToPt(props['fo:font-size']);
|
||||
const defSize = headingLevel != null ? HEADING_DEFAULTS[headingLevel - 1].fontSizePt : BODY_FONT_SIZE_PT;
|
||||
if (sizePt != null && Math.abs(sizePt - defSize) > 0.05) textStyle.fontSize = formatPt(sizePt);
|
||||
if (sizePt != null && Math.abs(sizePt - defaults.fontSizePt) > 0.05) textStyle.fontSize = formatPt(sizePt);
|
||||
|
||||
const family = resolver.fontFamilyOf(props);
|
||||
const defaultFonts = headingLevel != null ? DEFAULT_HEADING_FONTS : DEFAULT_FONTS;
|
||||
if (family && !defaultFonts.has(family.toLowerCase())) textStyle.fontFamily = family;
|
||||
if (family && !defaults.fonts.has(family.toLowerCase())) textStyle.fontFamily = family;
|
||||
|
||||
if (Object.keys(textStyle).length) marks.push({ type: 'textStyle', attrs: textStyle });
|
||||
return marks;
|
||||
|
||||
@@ -136,6 +136,8 @@ export class StyleResolver {
|
||||
private sectionStyleEls = new Map<string, Element>();
|
||||
private mergedCache = new Map<string, { text: PropMap; para: PropMap; misc: PropMap }>();
|
||||
private stylesDoc: Document | null;
|
||||
private namedParagraphNames = new Set<string>();
|
||||
private displayNames = new Map<string, string>();
|
||||
|
||||
constructor(contentDoc: Document, stylesDoc: Document | null) {
|
||||
this.stylesDoc = stylesDoc;
|
||||
@@ -151,7 +153,32 @@ export class StyleResolver {
|
||||
const fontDecls = doc.getElementsByTagNameNS(NS.office, 'font-face-decls')[0];
|
||||
if (fontDecls) this.scanFontFaces(fontDecls);
|
||||
}
|
||||
for (const c of containers) this.scanContainer(c);
|
||||
for (const c of containers) this.scanContainer(c, c.localName === 'styles');
|
||||
}
|
||||
|
||||
// Paragraph styles from <office:styles> — the file's named styles (as opposed to the
|
||||
// automatic styles, which are direct formatting). Own props only; the chain stays a chain.
|
||||
namedParagraphStyles(): Map<string, { parent: string | null; display?: string; text: PropMap; para: PropMap }> {
|
||||
const out = new Map<string, { parent: string | null; display?: string; text: PropMap; para: PropMap }>();
|
||||
for (const name of this.namedParagraphNames) {
|
||||
const entry = this.styles.get(`paragraph\0${name}`);
|
||||
// display: only the file's own style:display-name — the caller decodes _20_ itself.
|
||||
if (entry) out.set(name, { parent: entry.parent, display: this.displayNames.get(name), text: entry.text, para: entry.para });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private scanFontFaces(container: Element): void {
|
||||
@@ -179,12 +206,17 @@ export class StyleResolver {
|
||||
return this.fontSources;
|
||||
}
|
||||
|
||||
private scanContainer(container: Element): void {
|
||||
private scanContainer(container: Element, named: boolean): void {
|
||||
for (const el of Array.from(container.children)) {
|
||||
if (el.namespaceURI === NS.style && el.localName === 'style') {
|
||||
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);
|
||||
const display = el.getAttributeNS(NS.style, 'display-name');
|
||||
if (display) this.displayNames.set(name, display);
|
||||
}
|
||||
if (name && family === 'section') this.sectionStyleEls.set(name, el);
|
||||
} else if (el.namespaceURI === NS.style && el.localName === 'default-style') {
|
||||
const family = el.getAttributeNS(NS.style, 'family');
|
||||
|
||||
@@ -49,7 +49,7 @@ export const HEADING_PARENT = 'Heading';
|
||||
// asserts the two agree).
|
||||
const BUILTINS: Style[] = [
|
||||
{ name: DEFAULT_STYLE, parent: null, next: null, builtin: true,
|
||||
para: {}, text: { fontFamily: 'Liberation Serif', fontSizePt: 12 } },
|
||||
para: { spaceBefore: 0, spaceAfter: 0 }, text: { fontFamily: 'Liberation Serif', fontSizePt: 12 } },
|
||||
{ name: HEADING_PARENT, parent: DEFAULT_STYLE, next: DEFAULT_STYLE, builtin: true,
|
||||
para: { spaceBefore: 12, spaceAfter: 6 }, text: { fontFamily: 'Arial', bold: true } },
|
||||
...[18, 16, 14, 13, 12].map((size, i) => ({
|
||||
|
||||
+55
-1
@@ -9,6 +9,7 @@ import { buildOdt } from '../src/lib/export/odt';
|
||||
import { importOdt } from '../src/lib/import/odt';
|
||||
import { hfExtensions } from '../src/lib/editor/extensions/headerFooter';
|
||||
import { HEADER_SHADE } from '../src/lib/editor/extensions/tableHeaderRow';
|
||||
import { builtinStyleSheet } from '../src/lib/styles/styleSheet';
|
||||
|
||||
type N = any;
|
||||
|
||||
@@ -592,8 +593,14 @@ describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => {
|
||||
check('foreign: text:a → link mark (href preserved)',
|
||||
p2.content!.some((n: N) => n.text === 'a link' && n.marks?.some((m: N) => m.type === 'link' && m.attrs?.href === 'https://x.example')), p2.content);
|
||||
|
||||
// A named style's formatting lands in the style registry, not on the block: the
|
||||
// paragraph just references "Mono" (font-face resolved to the real family).
|
||||
const mono = c[3];
|
||||
check('foreign: font-face resolves Courier New', mono.content![0].marks?.some((m: N) => m.type === 'textStyle' && m.attrs?.fontFamily === 'Courier New' && m.attrs?.fontSize === '10pt'), mono.content![0]);
|
||||
check('foreign: paragraph references its named style', mono.attrs?.styleName === 'Mono', mono.attrs);
|
||||
const monoStyle = f.styles.paragraph['Mono'];
|
||||
check('foreign: Mono style resolves the font face', monoStyle?.text.fontFamily === 'Courier New', monoStyle);
|
||||
check('foreign: Mono style keeps its size', monoStyle?.text.fontSizePt === 10, monoStyle);
|
||||
check('foreign: no direct formatting on the block', !mono.content![0].marks, mono.content![0]);
|
||||
|
||||
check('foreign: image imported, footnote dropped, text kept',
|
||||
c[4].content!.length === 2 && c[4].content![0].text === 'img:' &&
|
||||
@@ -1305,3 +1312,50 @@ describe('Leg 11: heading levels 4 and 5', () => {
|
||||
check('no explicit size mark on h5', !(blocks[1]?.content?.[0]?.marks ?? []).length, blocks[1]?.content);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Leg 12: named paragraph styles (ODF)', () => {
|
||||
const sheet = builtinStyleSheet();
|
||||
sheet.paragraph['Merksatz'] = {
|
||||
name: 'Merksatz', parent: 'Standard', next: 'Standard',
|
||||
para: { indent: 2, spaceBefore: 6 }, text: { bold: true, color: '#0000AA' },
|
||||
};
|
||||
|
||||
const doc: N = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
H({ level: 1 }, T('Kapitel')),
|
||||
P({ styleName: 'Merksatz' }, T('gemerkt')),
|
||||
// Hard formatting on top of the style must stay direct formatting.
|
||||
P({ styleName: 'Merksatz', spaceAfter: 20 }, T('mit Abstand')),
|
||||
P({ styleName: 'Quotations' }, T('zitiert')),
|
||||
],
|
||||
};
|
||||
|
||||
it('writes real named styles and round-trips the 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']);
|
||||
|
||||
const merk = styles.match(/<style:style style:name="Merksatz"[\s\S]*?<\/style:style>/)?.[0] ?? '';
|
||||
check('mints the user style with its parent', merk.includes('style:parent-style-name="Standard"'), merk);
|
||||
check('user style carries its own props', merk.includes('fo:margin-left="2cm"') && merk.includes('fo:color="#0000AA"'), merk);
|
||||
check('heading styles inherit from Heading', /style:name="Heading_20_1"[\s\S]*?style:parent-style-name="Heading"/.test(styles));
|
||||
check('the Heading parent holds the sans font', /style:name="Heading"[\s\S]*?style:font-name="Arial"/.test(styles));
|
||||
check('no leftover sentinel', !content.includes('\uE00D'));
|
||||
|
||||
const res = importOdt(bytes);
|
||||
const blocks = res.content.content ?? [];
|
||||
check('block references its style', blocks[1]?.attrs?.styleName === 'Merksatz', blocks[1]?.attrs);
|
||||
check('built-in style assignment round-trips', blocks[3]?.attrs?.styleName === 'Quotations', blocks[3]?.attrs);
|
||||
check('hard formatting stays direct', blocks[2]?.attrs?.spaceAfter === 20, blocks[2]?.attrs);
|
||||
check('style formatting is NOT copied onto the block', !blocks[1]?.content?.[0]?.marks, blocks[1]?.content?.[0]);
|
||||
|
||||
const imported = res.styles.paragraph['Merksatz'];
|
||||
check('the style itself round-trips', imported?.parent === 'Standard', imported);
|
||||
check('with its own properties', imported?.text.bold === true && imported?.text.color === '#0000AA'
|
||||
&& imported?.para.indent === 2 && imported?.para.spaceBefore === 6, imported);
|
||||
// A heading keeps rendering from the registry, not from copied attrs.
|
||||
check('heading needs no direct formatting', !blocks[0]?.content?.[0]?.marks, blocks[0]);
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,8 @@ describe('exported Standard style', () => {
|
||||
const styles = strFromU8(unzipSync(bytes)['styles.xml']);
|
||||
|
||||
const standard = styles.match(/<style:style style:name="Standard"[\s\S]*?<\/style:style>/)![0];
|
||||
expect(standard).toContain('fo:margin-bottom="0cm"');
|
||||
// The Standard style declares its zero spacing (LibreOffice's Default Paragraph Style).
|
||||
expect(standard).toMatch(/fo:margin-bottom="0(pt|cm)"/);
|
||||
expect(standard).not.toContain('0.212cm');
|
||||
});
|
||||
});
|
||||
@@ -28,24 +28,30 @@ function odtWithHeading(base: string, size: string): Uint8Array {
|
||||
return zipSync({ 'content.xml': strToU8(content), 'styles.xml': strToU8(styles) });
|
||||
}
|
||||
|
||||
const sizeOf = (base: string, size: string) => {
|
||||
// The size a heading renders at: from the imported style registry, since a named
|
||||
// style's formatting is no longer copied onto the block.
|
||||
const styleSizeOf = (base: string, size: string) =>
|
||||
importOdt(odtWithHeading(base, size)).styles.paragraph['Heading 1']?.text.fontSizePt;
|
||||
|
||||
const markSizeOf = (base: string, size: string) => {
|
||||
const marks = (importOdt(odtWithHeading(base, size)).content.content?.[0] as any).content[0].marks ?? [];
|
||||
return marks.find((m: any) => m.type === 'textStyle')?.attrs?.fontSize;
|
||||
};
|
||||
|
||||
describe('percentage font sizes in the ODF style chain', () => {
|
||||
it('resolves a percentage that lands on the editor default (no explicit mark)', () => {
|
||||
// 12pt × 150% = 18pt = HEADING_STYLE_OVERRIDES[0] → suppressed like any default.
|
||||
expect(sizeOf('12pt', '150%')).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the file's own size when the percentage differs from the default", () => {
|
||||
expect(sizeOf('16pt', '125%')).toBe('20pt');
|
||||
expect(sizeOf('12pt', '200%')).toBe('24pt');
|
||||
it('resolves a percentage against the parent style', () => {
|
||||
expect(styleSizeOf('12pt', '150%')).toBe(18);
|
||||
expect(styleSizeOf('16pt', '125%')).toBe(20);
|
||||
expect(styleSizeOf('14pt', '130%')).toBe(18.2);
|
||||
});
|
||||
|
||||
it('still reads absolute sizes', () => {
|
||||
expect(sizeOf('14pt', '24pt')).toBe('24pt');
|
||||
expect(styleSizeOf('14pt', '24pt')).toBe(24);
|
||||
});
|
||||
|
||||
it('leaves the block free of direct formatting either way', () => {
|
||||
expect(markSizeOf('12pt', '150%')).toBeUndefined();
|
||||
expect(markSizeOf('16pt', '125%')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('named paragraph styles', () => {
|
||||
expect(h1.para.spaceBefore).toBe(12); // from Heading
|
||||
const standard = resolveStyle(sheet, DEFAULT_STYLE);
|
||||
expect(standard.text.fontFamily).toBe('Liberation Serif');
|
||||
expect(standard.para.spaceBefore).toBeUndefined();
|
||||
expect(standard.para.spaceBefore).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to Standard for unknown names and survives a parent cycle', () => {
|
||||
|
||||
Reference in new issue
Block a user