diff --git a/src/lib/editor/extensions/textBox.ts b/src/lib/editor/extensions/textBox.ts index dfd5137..86874fb 100644 --- a/src/lib/editor/extensions/textBox.ts +++ b/src/lib/editor/extensions/textBox.ts @@ -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); diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 6543421..9598322 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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 ( `` + - `` + `` ); diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index b85471d..1b4f39b 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -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) }; }