fix: named styles and floating boxes read clean through ODT

- what a block's named style supplies (Title's centring, keep-with-next,
  its size as a raised run's percent reference) is direct formatting no more
- a topBottom box keeps its left place instead of gaining a fabricated
  centring; horizontal-pos right reads back as the box's wrapAlign
- a table margin's zero side stays the default instead of an explicit 0
This commit is contained in:
Steffen Becker committed 2026-08-16 12:50:04 +02:00
1 parent 5bf470f4b7
commit d32d40be3c
2 files changed
+58 -22

No files matched your search

+14 -9
View File
@@ -3053,7 +3053,7 @@ function charFormatting(sheet: StyleSheet, name: string): Record<string, unknown
// Prefix the sentinel on every run carrying such an effect, wherever it sits — odf-kit
// reads marks itself on its native paths, so the pre-pass is what reaches all of them.
function markTextEffects(node: TiptapNode, baseSizePt = DEFAULT_FONT_SIZE_PT): TiptapNode {
function markTextEffects(node: TiptapNode, baseSizePt = DEFAULT_FONT_SIZE_PT, sheet?: StyleSheet): TiptapNode {
if (node.type === 'text') {
const extra = odfExtraTextProps(node.marks, baseSizePt);
// The empty trailing pair marks where the run ends: a bare run following would
@@ -3061,10 +3061,14 @@ function markTextEffects(node: TiptapNode, baseSizePt = DEFAULT_FONT_SIZE_PT): T
return extra ? { ...node, text: `${TEF}${extra}${TEF}${node.text ?? ''}${TEF}${TEF}` } : node;
}
if (!node.content?.length) return node;
// A named style's size is the reference for a raised run's percentage (a Title's
// -2pt is a smaller share of its 28pt); import resolves against the same chain.
const named = sheet && typeof node.attrs?.styleName === 'string'
? resolveStyle(sheet, node.attrs.styleName, 'paragraph').text.fontSizePt : null;
const base = node.type === 'heading' || node.type === CUST_H
? parseFloat(HEADING_STYLE_OVERRIDES[Math.min(Math.max(Number(node.attrs?.level) || 1, 1), HEADING_STYLE_OVERRIDES.length) - 1].fontSize)
: baseSizePt;
return { ...node, content: node.content.map((c) => markTextEffects(c, base)) };
: named ?? baseSizePt;
return { ...node, content: node.content.map((c) => markTextEffects(c, base, sheet)) };
}
// odf-kit's list builder reads a run's marks itself and knows nothing about charStyle,
@@ -3670,15 +3674,16 @@ function imageTransform(img: ImageExport): string {
}
// ODF style:wrap is the side TEXT flows on (inverse of the image side); horizontal-pos
// places the frame on that side. topBottom ⇒ no wrap, centred.
// An offset frame is placed by coordinate instead (svg:x on the frame).
function imageWrapProps(wrap: WrapMode, offset: number | null, align?: string | null, distCm?: number | null): string {
// places the frame on that side. topBottom ⇒ no wrap, placed at noneAlign — centred
// for an image, but a box with no align of its own sits at the left, as the editor
// draws it. An offset frame is placed by coordinate instead (svg:x on the frame).
function imageWrapProps(wrap: WrapMode, offset: number | null, align?: string | null, distCm?: number | null, noneAlign = 'center'): string {
const pos = offset != null ? 'from-left' : null;
// Only the text side is written; the frame's own offset covers the other one.
const gap = distCm ? ` fo:margin-${wrap === 'right' ? 'left' : 'right'}="${distCm}cm"` : '';
if (wrap === 'left') return `style:wrap="right" style:horizontal-pos="${pos ?? 'left'}"${gap}`;
if (wrap === 'right') return `style:wrap="left" style:horizontal-pos="${pos ?? 'right'}"${gap}`;
return `style:wrap="none" style:horizontal-pos="${align ?? pos ?? 'center'}"`;
return `style:wrap="none" style:horizontal-pos="${align ?? pos ?? noneAlign}"`;
}
// The inverse of the importer's as-char alignment map (import/odt.ts): a frame with no
@@ -3949,7 +3954,7 @@ function textBoxGraphicStyle(box: TextBoxExport, index: number): string {
// frame, and the anchor paragraph this export mints carries no alignment.
const wrap = box.wrap === 'inline'
? (box.wrapAlign ? ` style:horizontal-pos="${box.wrapAlign}" style:horizontal-rel="paragraph-content"` : '')
: ` ${imageWrapProps(box.wrap, box.wrapOffsetCm, box.wrapAlign, box.wrapDistCm)} style:number-wrapped-paragraphs="no-limit"` +
: ` ${imageWrapProps(box.wrap, box.wrapOffsetCm, box.wrapAlign, box.wrapDistCm, 'left')} style:number-wrapped-paragraphs="no-limit"` +
` style:horizontal-rel="paragraph-content"` +
` style:vertical-pos="${box.wrapOffsetYCm != null ? 'from-top' : 'top'}" style:vertical-rel="paragraph"`;
// auto-grow only for plain text boxes; a custom-shape needs both explicitly
@@ -4458,7 +4463,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const bibMarks: BibExport[] = [];
const rubies: RubyExport[] = [];
const sentinels = replaceRuby(replaceBibEntries(replaceIndexEntries(replaceRevisions(replaceSequenceFields(replaceComments(replaceBookmarks(replaceFormulas(replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceSectionBreaks(replaceNotes(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns), notes)))), images), dateFields), formulas), crossRefs), commentList), seqFields), revisionList), indexMarks), bibMarks), rubies);
const raw = markTextEffects(bakeListCharStyles(sentinels, styles));
const raw = markTextEffects(bakeListCharStyles(sentinels, styles), DEFAULT_FONT_SIZE_PT, styles);
let headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null;
let footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null;
// Different first page (ODF header-first): page 1 gets its own zone content.
+44 -13
View File
@@ -209,6 +209,16 @@ function applyInlineVAlign(el: Element, attrs: Record<string, unknown>, gp: Prop
else if (pos === 'top' && area) attrs.vAlign = 'text-top';
}
// A box is a block of its own, so a figure frame's centring — which its as-char anchor
// would otherwise give it — rides the box. Never at a side float (horizontal-pos is the
// float's side there); the image band-pairing left/right mapping is not a box's either.
function boxWrapAlign(gp: PropMap, attrs: Record<string, unknown>): void {
delete attrs.wrapAlign;
const hpos = gp['style:horizontal-pos'];
if ((hpos === 'center' || hpos === 'right') && attrs.wrapOffset == null
&& attrs.wrap !== 'left' && attrs.wrap !== 'right') attrs.wrapAlign = hpos;
}
function applyFrameRotationAndWrap(el: Element, attrs: Record<string, unknown>, gp: PropMap): void {
const deg = frameRotationDeg(el);
if (deg) attrs.rotation = deg;
@@ -417,10 +427,7 @@ function convertTextBoxFrame(frame: Element, textBoxEl: Element, ctx: Ctx): Node
if (hCm != null) attrs.height = framePx(cmToPx(hCm));
const gp = ctx.resolver.graphicProps(frame.getAttributeNS(NS.draw, 'style-name'));
applyFrameRotationAndWrap(frame, attrs, gp);
// A box is a block of its own here, so the centring of a figure frame — which its
// as-char anchor paragraph would otherwise give it — has to ride the box itself.
// An image needs none: with no side to float to it is centred anyway.
if (gp['style:horizontal-pos'] === 'center' && attrs.wrapOffset == null) attrs.wrapAlign = 'center';
boxWrapAlign(gp, attrs);
const padCm = lengthToCm(gp['fo:padding']);
if (padCm != null && Math.abs(padCm - TEXTBOX_PADDING_CM) > 0.01) attrs.paddingCm = Math.round(padCm * 1000) / 1000;
shapeStyleAttrs(gp, attrs, false);
@@ -533,6 +540,7 @@ function convertShape(el: Element, ctx: Ctx): Node | null {
if (hCm != null) attrs.height = framePx(cmToPx(hCm));
const gp = ctx.resolver.graphicProps(el.getAttributeNS(NS.draw, 'style-name'));
applyFrameRotationAndWrap(el, attrs, gp);
boxWrapAlign(gp, attrs);
shapeStyleAttrs(gp, attrs, true);
return { type: 'textBox', attrs, content: textBoxContent(Array.from(el.children), ctx) };
}
@@ -577,6 +585,7 @@ function convertFreeform(el: Element, ctx: Ctx): Node | null {
};
const gp = ctx.resolver.graphicProps(el.getAttributeNS(NS.draw, 'style-name'));
applyFrameRotationAndWrap(el, attrs, gp);
boxWrapAlign(gp, attrs);
shapeStyleAttrs(gp, attrs, true);
return { type: 'textBox', attrs, content: [{ type: 'paragraph' }] };
}
@@ -597,6 +606,7 @@ function convertLine(el: Element, ctx: Ctx): Node | null {
// The editor draws a line across its frame, so only the direction is left to keep.
if ((y2 - y1) * (x2 - x1) < 0) attrs.flipV = true;
applyFrameRotationAndWrap(el, attrs, gp);
boxWrapAlign(gp, attrs);
shapeStyleAttrs(gp, attrs, true);
return { type: 'textBox', attrs, content: [{ type: 'paragraph' }] };
}
@@ -1267,6 +1277,10 @@ type BlockDefaults = {
// The named style's line spacing as an ODF factor; a block declaring the same one adds
// nothing, but a block that declares 100% under a 115% style is direct formatting.
lineHeight: number;
// The style's own alignment and flow flags (Title centres, Heading keeps with next).
textAlign: string | null;
keepNext: boolean;
keepLines: boolean;
boldByDefault: boolean;
fonts: Set<string>;
color: string;
@@ -1294,6 +1308,14 @@ function linePercent(lh: string | undefined): number | null {
return Number.isFinite(p) ? Math.round(p) / 100 : null;
}
// fo:text-align → the editor's four values (start/end read as an LTR page), or null.
function odfTextAlign(ta: string | undefined): string | null {
if (ta === 'center' || ta === 'justify') return ta;
if (ta === 'right' || ta === 'end') return 'right';
if (ta === 'left' || ta === 'start') return 'left';
return null;
}
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 = {
@@ -1302,6 +1324,9 @@ function blockDefaults(resolver: StyleResolver, named: string | null, headingLev
marginBottomPt: hdef ? hdef.marginBottomPt : 0,
indentPt: 0,
lineHeight: 1,
textAlign: null,
keepNext: false,
keepLines: false,
boldByDefault: headingLevel != null || boldByDefault,
fonts: new Set(headingLevel != null ? DEFAULT_HEADING_FONTS : DEFAULT_FONTS),
color: '#000000',
@@ -1327,6 +1352,9 @@ function blockDefaults(resolver: StyleResolver, named: string | null, headingLev
marginBottomPt: lengthToPt(para['fo:margin-bottom']) ?? 0,
indentPt: lengthToPt(para['fo:margin-left']) ?? 0,
lineHeight: linePercent(para['fo:line-height']) ?? 1,
textAlign: odfTextAlign(para['fo:text-align']),
keepNext: para['fo:keep-with-next'] === 'always',
keepLines: para['fo:keep-together'] === 'always',
boldByDefault: weight ? weight === 'bold' || parseInt(weight, 10) >= 600 : fallback.boldByDefault,
fonts,
color: (text['fo:color'] && normalizeColor(text['fo:color'])) || fallback.color,
@@ -1565,10 +1593,10 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault =
// paragraph-mark size), or text smaller than the style keeps the taller strut.
const markSize = lengthToPt(baseTextProps['fo:font-size']);
if (markSize != null && Math.abs(markSize - defaults.fontSizePt) > 0.05) attrs.fontSize = formatPt(markSize);
// Keep with next: a heading does that anyway (pageBreaks.ts), so only a plain
// paragraph carries it — otherwise every heading would accrete the producer's flag.
if (!isHeading && paraProps['fo:keep-with-next'] === 'always') attrs.keepNext = true;
if (!isHeading && paraProps['fo:keep-together'] === 'always') attrs.keepLines = true;
// Keep with next: a heading does that anyway (pageBreaks.ts), and what the block's
// own style supplies (Title keeps with next) is not direct formatting either.
if (!isHeading && !defaults.keepNext && paraProps['fo:keep-with-next'] === 'always') attrs.keepNext = true;
if (!isHeading && !defaults.keepLines && paraProps['fo:keep-together'] === 'always') attrs.keepLines = true;
// "Don't hyphenate this paragraph" — only meaningful where the document hyphenates at
// all; below that switch it is the default and no formatting.
if (baseTextProps['fo:hyphenate'] === 'false' && resolver.documentHyphenation()) attrs.noHyphenation = true;
@@ -1601,9 +1629,10 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault =
function blockAttrs(paraProps: PropMap, textProps: PropMap, defaults: BlockDefaults, kind: BlockKind): Record<string, unknown> {
const attrs: Record<string, unknown> = {};
const ta = paraProps['fo:text-align'];
if (ta === 'center' || ta === 'justify') attrs.textAlign = ta;
else if (ta === 'right' || ta === 'end') attrs.textAlign = 'right';
// Measured against the named style's own alignment: what the style supplies is not
// direct formatting, and a block overriding it back to left needs the attr to win.
const ta = odfTextAlign(paraProps['fo:text-align']);
if (ta !== null && ta !== (defaults.textAlign ?? 'left')) attrs.textAlign = ta;
const lh = paraProps['fo:line-height'];
if (lh && lh !== 'normal') {
@@ -2662,7 +2691,7 @@ function odfTableLook(el: Element): string | null {
// A table narrower than the text width → the editor's marginLeft/marginRight attrs.
// LibreOffice states the width plus one margin (per table:align), the other follows.
function tableMargins(el: Element, ctx: Ctx): { marginLeft: number; marginRight: number } | null {
function tableMargins(el: Element, ctx: Ctx): { marginLeft?: number; marginRight?: number } | null {
const props = ctx.resolver.tableProps(el.getAttributeNS(NS.table, 'style-name'));
const content = ctx.contentWidthCm;
const rel = parseFloat(props['style:rel-width'] ?? '');
@@ -2682,7 +2711,9 @@ function tableMargins(el: Element, ctx: Ctx): { marginLeft: number; marginRight:
if (Math.abs(left) < 0.05 && Math.abs(right) < 0.05) return null;
if (left + right > content - 1) return null;
const round2 = (v: number) => Math.round(v * 100) / 100;
return { marginLeft: round2(left), marginRight: round2(right) };
const l = round2(left), r = round2(right);
// A zero side is the attr's default (null); rounding may also leave a -0 behind.
return { ...(Math.abs(l) >= 0.005 ? { marginLeft: l } : {}), ...(Math.abs(r) >= 0.005 ? { marginRight: r } : {}) };
}
// The space a table's own style puts above and below it (fo:margin-top/-bottom on