fix: a text box takes the padding its file declares

The inset ring was a fixed 0.15cm the editor imposed on every box, so a
figure frame — which declares 0 — sat 1.5mm right of where LibreOffice
draws it and stood 3mm taller, on every figure of the document.
This commit is contained in:
Steffen Becker committed 2026-08-10 10:58:47 +00:00
1 parent 615f2af52d
commit f81109a434
3 files changed
+18 -4

No files matched your search

+12 -3
View File
@@ -21,7 +21,7 @@ export type ShapeKind = 'textbox' | 'roundRect' | 'ellipse';
// Fixed text inset inside the frame; exported as fo:padding / wps:bodyPr insets.
export const TEXTBOX_PADDING_CM = 0.15;
const PADDING_PX = (TEXTBOX_PADDING_CM * 96) / 2.54;
const paddingPx = (cm: unknown) => ((typeof cm === 'number' ? cm : TEXTBOX_PADDING_CM) * 96) / 2.54;
// Extra per-axis inset that fits the text area into an ellipse's inscribed
// rectangle (half-axis / √2), so text follows the oval instead of the bbox.
@@ -39,6 +39,7 @@ export interface TextBoxAttrs {
wrapOffset: number | null; // cm from the text column's left edge
wrapOffsetY: number | null; // cm below the anchor paragraph
wrapAlign: string | null; // 'center' = set against the middle of the column
paddingCm: number; // inset ring around the text (ODF fo:padding)
shapeKind: ShapeKind;
fillColor: string | null;
strokeColor: string | null;
@@ -125,6 +126,13 @@ export const TextBox = Node.create({
parseHTML: el => (el as HTMLElement).getAttribute('data-wrap-align') || null,
renderHTML: () => ({}),
},
// The file's own fo:padding (cm). A figure frame declares 0, and the default
// ring would make every such box a little taller and its text a little inset.
paddingCm: {
default: TEXTBOX_PADDING_CM,
parseHTML: el => parseCmAttr((el as HTMLElement).getAttribute('data-padding')) ?? TEXTBOX_PADDING_CM,
renderHTML: () => ({}),
},
shapeKind: {
default: 'textbox',
parseHTML: el => (el as HTMLElement).getAttribute('data-shape') ?? 'textbox',
@@ -164,7 +172,7 @@ export const TextBox = Node.create({
a.strokeColor ? `border:${a.strokeWidthPt * PX_PER_PT}px solid ${a.strokeColor}` : '',
a.shapeKind !== 'textbox' ? `border-radius:${shapeRadius(a.shapeKind)}` : '',
a.rotation ? `transform:rotate(${a.rotation}deg)` : '',
`padding:${PADDING_PX.toFixed(2)}px`,
`padding:${paddingPx(a.paddingCm).toFixed(2)}px`,
].filter(Boolean).join(';');
return ['div', mergeAttributes(HTMLAttributes, {
'data-textbox': '',
@@ -175,6 +183,7 @@ export const TextBox = Node.create({
...(a.fillColor ? { 'data-fill': a.fillColor } : {}),
...(a.strokeColor ? { 'data-stroke': a.strokeColor } : {}),
...(a.strokeWidthPt !== 1 ? { 'data-stroke-width': String(a.strokeWidthPt) } : {}),
...(a.paddingCm !== TEXTBOX_PADDING_CM ? { 'data-padding': String(a.paddingCm) } : {}),
}), 0];
},
@@ -270,7 +279,7 @@ class TextBoxView {
// Padding lives on the rotor, so the inset ring around the text is frame
// (click-to-select) area rather than content.
this.rotor.style.padding = `${PADDING_PX.toFixed(2)}px`;
this.rotor.style.padding = `${paddingPx(this.attrs().paddingCm).toFixed(2)}px`;
this.contentDOM = document.createElement('div');
this.contentDOM.className = 'textbox-content';
this.rotor.appendChild(this.contentDOM);
+3 -1
View File
@@ -570,6 +570,7 @@ type TextBoxExport = {
wrapOffsetCm: number | null;
wrapOffsetYCm: number | null;
wrapAlign: string | null;
paddingCm: number;
shapeKind: ShapeKind;
fill: string | null;
stroke: string | null;
@@ -590,6 +591,7 @@ function textBoxDescriptor(node: TiptapNode): TextBoxExport {
wrapOffsetCm: typeof a.wrapOffset === 'number' ? round3(a.wrapOffset) : null,
wrapOffsetYCm: typeof a.wrapOffsetY === 'number' ? round3(a.wrapOffsetY) : null,
wrapAlign: a.wrapAlign === 'center' ? 'center' : null,
paddingCm: typeof a.paddingCm === 'number' ? round3(a.paddingCm) : TEXTBOX_PADDING_CM,
shapeKind: kind === 'roundRect' || kind === 'ellipse' ? kind : 'textbox',
fill: typeof a.fillColor === 'string' && a.fillColor ? a.fillColor : null,
stroke: typeof a.strokeColor === 'string' && a.strokeColor ? a.strokeColor : null,
@@ -3038,7 +3040,7 @@ function textBoxGraphicStyle(box: TextBoxExport, index: number): string {
: ' draw:auto-grow-height="false" draw:auto-grow-width="false"';
return (
`<style:style style:name="TbxFr${index + 1}" style:family="graphic">` +
`<style:graphic-properties ${fill} ${stroke} fo:padding="${TEXTBOX_PADDING_CM}cm"` +
`<style:graphic-properties ${fill} ${stroke} fo:padding="${box.paddingCm}cm"` +
`${grow} draw:textarea-vertical-align="top"${wrap}/>` +
`</style:style>`
);
+3
View File
@@ -22,6 +22,7 @@ import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/
import type { HfDoc, HfSet } from '../storage/headerFooter';
import type { EmbeddedFont } from '../fonts/embeddedFonts';
import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding';
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
import { getSchema } from '@tiptap/core';
import type { Schema } from '@tiptap/pm/model';
import { hfExtensions } from '../editor/extensions/headerFooter';
@@ -348,6 +349,8 @@ function convertTextBoxFrame(frame: Element, textBoxEl: Element, ctx: Ctx): Node
// 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';
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);
return { type: 'textBox', attrs, content: textBoxContent(Array.from(textBoxEl.children), ctx) };
}