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:
1 parent
5bf470f4b7
commit
d32d40be3c
2 files changed
+58
-22
No files matched your search
+14
-9
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user