From 97f9016369aded28ed427ebfca40c9ddf5c44abe Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sun, 16 Aug 2026 07:35:42 +0200 Subject: [PATCH] fix: a note's stock body style no longer imports as direct formatting Both importers stamped the exporter's own note style back onto the note (styleName "Footnote" from ODT, "footnote text" from DOCX), so an editor-authored document accreted a foreign attr on every round trip and the two formats disagreed. A style that only re-states the stock note look is now suppressed like any other default-equal value; a customized one still lands. --- src/lib/import/docx.ts | 16 +++++++++++++++- src/lib/import/odt.ts | 23 +++++++++++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 0e49431..ce39bcb 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -1188,15 +1188,29 @@ function noteRefNode(wid: string | null, kind: NoteKind, ctx: Ctx, baseRun: RunP const id = `${kind}${wid}`; // The note renders at the file's own size and indent: its first paragraph names the // style (Word's FootnoteText), and collectStyleSheet only keeps a style in use. + // A style that only re-states the stock look (10pt, nothing else changed against the + // default) equals a styleName-less note and is suppressed like any default-equal value. const firstPara = Array.from(note.children).find((c) => c.namespaceURI === W && c.localName === 'p'); const pStyle = fc(fc(firstPara ?? null, 'pPr'), 'pStyle'); const styleId = pStyle ? wVal(pStyle) : null; - const styleName = styleId ? ctx.styleNames.get(styleId) ?? null : null; + const styleName = styleId && !isStockNoteStyle(ctx, styleId) ? ctx.styleNames.get(styleId) ?? null : null; if (styleId && styleName) ctx.usedStyles.add(styleId); ctx.notes.push({ id, kind, text, content, styleName }); return { type: 'noteRef', attrs: { id, kind, text } }; } +// A note style is "stock" when it resolves to 10pt over the default style with nothing +// else of its own — the look a styleName-less note already renders. +function isStockNoteStyle(ctx: Ctx, styleId: string): boolean { + const run = ctx.styles.paragraphRun(styleId), base = ctx.styles.paragraphRun(null); + if (run.sizeHalfPt !== 20) return false; + const keys = ['bold', 'italic', 'underline', 'strike', 'color', 'font', 'fontTheme', 'highlightFill', 'caps'] as const; + if (!keys.every((k) => (run[k] ?? null) === (base[k] ?? null))) return false; + if (ctx.styles.paragraphAlign(styleId) !== ctx.styles.paragraphAlign(null)) return false; + const sp = ctx.styles.paragraphSpacing(styleId); + return !sp.before && !sp.after && (ctx.styles.styleIndentTwip(styleId) ?? 0) === 0; +} + function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockDefaults, hfFields: boolean): Node[] { const out: Node[] = []; let fieldMode: 'none' | 'instr' | 'result' = 'none'; diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index b601b55..da091f2 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -23,7 +23,7 @@ import type { SpacingModel } from '../storage/spacingModel'; import { pageDimsCm, type PageFormat } from '../storage/pageFormat'; import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage'; import type { HfDoc, HfSet } from '../storage/headerFooter'; -import type { NoteKind, NoteSettings } from '../storage/noteSettings'; +import { NOTE_FONT_SIZE_PT, NOTE_INDENT_CM, type NoteKind, type NoteSettings } from '../storage/noteSettings'; import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties'; import { clampPageStart, type PageNumbering } from '../storage/pageNumbering'; import { newCommentId } from '../editor/extensions/comment'; @@ -1807,6 +1807,25 @@ function convertSequenceField(e: Element): Node | null { }; } +// A note style that only re-states the stock look (10pt, the hanging indent, nothing +// else changed against the default style) equals a styleName-less note; suppress it, +// as every other default-equal value is on import. +function isStockNoteStyle(resolver: StyleResolver, name: string): boolean { + // Measured against Standard (the stock styles' parent), so inherited values match. + const text = resolver.paraTextProps(name), baseT = resolver.paraTextProps('Standard'); + const para = resolver.paraProps(name), baseP = resolver.paraProps('Standard'); + const cm = (v?: string) => lengthToCm(v ?? null) ?? 0; + if (Math.abs((lengthToPt(text['fo:font-size']) ?? 0) - NOTE_FONT_SIZE_PT) > 0.3) return false; + if (Math.abs(cm(para['fo:margin-left']) - NOTE_INDENT_CM) > 0.03) return false; + if (Math.abs(cm(para['fo:text-indent']) + NOTE_INDENT_CM) > 0.03) return false; + const sameT = (k: string) => (text[k] ?? '') === (baseT[k] ?? ''); + const sameP = (k: string) => (para[k] ?? '') === (baseP[k] ?? ''); + const sameCm = (k: string) => Math.abs(cm(para[k]) - cm(baseP[k])) < 0.03; + return ['fo:font-family', 'style:font-name', 'fo:font-style', 'fo:font-weight', 'fo:color', 'fo:background-color', 'style:text-underline-style'].every(sameT) + && ['fo:text-align', 'fo:line-height'].every(sameP) + && ['fo:margin-top', 'fo:margin-bottom', 'fo:margin-right'].every(sameCm); +} + // The note's paragraph style: the one its own names, else the class's // configured default. Registered as used, or collectStyleSheet drops it. function noteBodyStyle(e: Element, ctx: Ctx, kind: NoteKind): string | null { @@ -1815,7 +1834,7 @@ function noteBodyStyle(e: Element, ctx: Ctx, kind: NoteKind): string | null { const raw = own?.getAttributeNS(NS.text, 'style-name') ?? ctx.resolver.noteSettings()[kind].bodyStyle.replace(/ /g, '_20_'); const named = ctx.resolver.namedAncestor(raw, 'paragraph'); - if (!named) return null; + if (!named || isStockNoteStyle(ctx.resolver, named)) return null; ctx.usedStyles.add(named); return ctx.styleNames.get(named) ?? named.replace(/_20_/g, ' '); }