diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 190786d..eb79af4 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -3053,7 +3053,7 @@ function charFormatting(sheet: StyleSheet, name: string): Record 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. diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 509da69..4482681 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -209,6 +209,16 @@ function applyInlineVAlign(el: Element, attrs: Record, 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): 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, 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; 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 { const attrs: Record = {}; - 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