fix ellipse text field

This commit is contained in:
Steffen Becker committed 2026-07-11 12:36:17 +02:00
1 parent 0526579f2b
commit a535738301
4 files changed
+99 -17

No files matched your search

+38 -2
View File
@@ -18,6 +18,10 @@ export type ShapeKind = 'textbox' | 'roundRect' | 'ellipse';
export const TEXTBOX_PADDING_CM = 0.15;
const PADDING_PX = (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.
const ELLIPSE_INSET_RATIO = (1 - 1 / Math.SQRT2) / 2;
const DEFAULT_WIDTH_PX = 280;
const DEFAULT_HEIGHT_PX = 96;
const PX_PER_PT = 96 / 72;
@@ -224,6 +228,9 @@ class TextBoxView {
private getPos: () => number;
private observer: ResizeObserver | null = null;
private resizing = false;
// Last ellipse text-area inset applied; guards the ResizeObserver feedback loop.
private lastInsetX = -1;
private lastInsetY = -1;
constructor(node: PMNode, editor: Editor, getPos: () => number) {
this.node = node;
@@ -265,8 +272,12 @@ class TextBoxView {
this.dom.addEventListener('mousedown', e => this.onFrameMouseDown(e));
// The rotor is out of flow and grows with its content; refit the wrapper to the
// rotated bounding box whenever the rendered size changes (typing, resizing).
this.observer = new ResizeObserver(() => this.fitWrapper());
// rotated bounding box whenever the rendered size changes (typing, resizing), and
// re-fit an ellipse's text area to its now-current height.
this.observer = new ResizeObserver(() => {
this.applyShapeInset();
this.fitWrapper();
});
this.observer.observe(this.rotor);
this.applyAll();
@@ -287,9 +298,30 @@ class TextBoxView {
this.rotor.style.borderRadius = shapeRadius(a.shapeKind);
this.rotor.style.transform = `translate(-50%, -50%) rotate(${a.rotation}deg)`;
this.applyWrap();
this.applyShapeInset();
this.fitWrapper();
}
// For an ellipse, pad the content into the inscribed rectangle so text stays inside
// the oval. The vertical inset feeds back into the auto-grown height, so rewrite it
// only past a threshold — the ResizeObserver then settles (ratio < 0.5 converges).
private applyShapeInset(): void {
if (this.attrs().shapeKind !== 'ellipse') {
if (this.contentDOM.style.padding) this.contentDOM.style.padding = '';
this.lastInsetX = this.lastInsetY = -1;
return;
}
const w = this.rotor.offsetWidth;
const h = this.rotor.offsetHeight;
if (!w || !h) return;
const insetX = w * ELLIPSE_INSET_RATIO;
const insetY = h * ELLIPSE_INSET_RATIO;
if (Math.abs(insetX - this.lastInsetX) < 0.5 && Math.abs(insetY - this.lastInsetY) < 0.5) return;
this.lastInsetX = insetX;
this.lastInsetY = insetY;
this.contentDOM.style.padding = `${insetY.toFixed(2)}px ${insetX.toFixed(2)}px`;
}
// Size the wrapper to the rotor's rotated bounding box so surrounding text
// reserves the right space (same math as ImageView.applyLayout).
private fitWrapper(): void {
@@ -443,6 +475,7 @@ class TextBoxView {
moved = true;
this.rotor.style.width = `${lastW}px`;
this.rotor.style.minHeight = `${lastH}px`;
this.applyShapeInset();
this.fitWrapper();
this.showBadge(lastW, lastH);
};
@@ -508,6 +541,9 @@ class TextBoxView {
}
ignoreMutation(m: MutationRecord | { type: 'selection'; target: globalThis.Node }): boolean {
// The ellipse inset padding we write to contentDOM is our own styling, not a content
// edit — without this PM rebuilds the node view mid-resize and the wrapper collapses.
if (m.type === 'attributes' && m.target === this.contentDOM) return true;
return !this.contentDOM.contains(m.target);
}
+1
View File
@@ -1787,6 +1787,7 @@ function applyDateTimeFields(odtBytes: Uint8Array, fields: DateTimeFieldExport[]
const ENHANCED_GEOMETRY: Record<'roundRect' | 'ellipse', string> = {
ellipse:
'<draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:type="ellipse"' +
' draw:text-areas="3163 3163 18437 18437"' +
' draw:enhanced-path="U 10800 10800 10800 10800 0 360 Z N"/>',
roundRect:
'<draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:type="round-rectangle" draw:modifiers="3600"' +
+21 -15
View File
@@ -138,17 +138,22 @@ function convertFrame(frame: Element, ctx: Ctx): Node | null {
}
// A shape's fill color. fo:background-color (LibreOffice's per-shape fill) wins over
// draw:fill when set — the two coexist because the default graphic style carries a
// draw:fill that a "no fill" shape overrides only via fo:background-color="transparent".
function shapeFill(gp: PropMap): string | null {
// draw:fill when set. A drawn shape's fill defaults to solid when the keyword is
// absent — LibreOffice omits draw:fill and only writes "none" to turn it off, taking
// the color from the (usually inherited) draw:fill-color. defaultSolid is false for
// plain text frames, whose fill defaults to none.
function shapeFill(gp: PropMap, defaultSolid: boolean): string | null {
const bg = gp['fo:background-color'];
if (bg !== undefined) return bg === 'transparent' || bg === 'none' ? null : normalizeColor(bg) ?? null;
return gp['draw:fill'] === 'solid' ? normalizeColor(gp['draw:fill-color'] ?? '') ?? null : null;
const fill = gp['draw:fill'];
const solid = fill === 'solid' || (fill === undefined && defaultSolid);
return solid ? normalizeColor(gp['draw:fill-color'] ?? '') ?? null : null;
}
// A shape's stroke as { color, widthPt }. fo:border ("<w> <style> <color>", LibreOffice's
// per-shape border) wins over draw:stroke for the same reason as shapeFill.
function shapeStroke(gp: PropMap): { color: string | null; widthPt: number | null } {
// per-shape border) wins over draw:stroke. Like the fill, a drawn shape's stroke defaults
// to solid when draw:stroke is absent (color from svg:stroke-color); "none" turns it off.
function shapeStroke(gp: PropMap, defaultSolid: boolean): { color: string | null; widthPt: number | null } {
const border = gp['fo:border'];
if (border !== undefined) {
let widthPt: number | null = null, color: string | null = null, styleTok: string | null = null;
@@ -162,19 +167,20 @@ function shapeStroke(gp: PropMap): { color: string | null; widthPt: number | nul
}
return { color: color ?? '#000000', widthPt };
}
const color = gp['draw:stroke'] && gp['draw:stroke'] !== 'none'
? normalizeColor(gp['svg:stroke-color'] ?? '#000000') ?? '#000000'
: null;
const stroke = gp['draw:stroke'];
const present = stroke === undefined ? defaultSolid && gp['svg:stroke-color'] !== undefined : stroke !== 'none';
const color = present ? normalizeColor(gp['svg:stroke-color'] ?? '#000000') ?? '#000000' : null;
return { color, widthPt: color ? lengthToPt(gp['svg:stroke-width']) : null };
}
// Fill/stroke attrs from a shape's graphic style, suppressing the editor defaults
// (white fill, 1pt black stroke). Absent/none → explicit null (transparent/no border),
// since omitting the attr would re-apply the default.
function shapeStyleAttrs(gp: PropMap, attrs: Record<string, unknown>): void {
const fillColor = shapeFill(gp);
// since omitting the attr would re-apply the default. defaultSolid marks drawn shapes,
// whose fill/stroke default to solid (unlike a plain text frame's).
function shapeStyleAttrs(gp: PropMap, attrs: Record<string, unknown>, defaultSolid: boolean): void {
const fillColor = shapeFill(gp, defaultSolid);
if (fillColor !== '#FFFFFF') attrs.fillColor = fillColor;
const { color: strokeColor, widthPt } = shapeStroke(gp);
const { color: strokeColor, widthPt } = shapeStroke(gp, defaultSolid);
if (strokeColor !== '#000000') attrs.strokeColor = strokeColor;
if (strokeColor && widthPt != null && Math.abs(widthPt - 1) > 0.1) {
attrs.strokeWidthPt = Math.round(widthPt * 100) / 100;
@@ -201,7 +207,7 @@ function convertTextBoxFrame(frame: Element, textBoxEl: Element, ctx: Ctx): Node
if (hCm != null) attrs.height = Math.round(cmToPx(hCm));
const gp = ctx.resolver.graphicProps(frame.getAttributeNS(NS.draw, 'style-name'));
applyFrameRotationAndWrap(frame, attrs, gp);
shapeStyleAttrs(gp, attrs);
shapeStyleAttrs(gp, attrs, false);
return { type: 'textBox', attrs, content: textBoxContent(Array.from(textBoxEl.children), ctx) };
}
@@ -231,7 +237,7 @@ function convertShape(el: Element, ctx: Ctx): Node | null {
if (hCm != null) attrs.height = Math.round(cmToPx(hCm));
const gp = ctx.resolver.graphicProps(el.getAttributeNS(NS.draw, 'style-name'));
applyFrameRotationAndWrap(el, attrs, gp);
shapeStyleAttrs(gp, attrs);
shapeStyleAttrs(gp, attrs, true);
return { type: 'textBox', attrs, content: textBoxContent(Array.from(el.children), ctx) };
}
+39
View File
@@ -588,6 +588,45 @@ describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => {
check('foreign: no hyperlink warning (now round-tripped)', !f.warnings.includes('Hyperlinks were converted to plain text'), f.warnings);
});
it('resolves a drawn shape\'s fill/stroke inherited from the default graphic style', () => {
// LibreOffice omits draw:fill/draw:stroke on a shape that keeps the app default
// (solid), taking the color from the default graphic style; only "none" turns it off.
const stylesXml = `<?xml version="1.0" encoding="UTF-8"?>
<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0">
<office:styles>
<style:default-style style:family="graphic">
<style:graphic-properties svg:stroke-color="#3465a4" draw:fill-color="#729fcf"/>
</style:default-style>
</office:styles>
</office:document-styles>`;
const contentXml = `<?xml version="1.0" encoding="UTF-8"?>
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0">
<office:automatic-styles>
<style:style style:name="grInherit" style:family="graphic"><style:graphic-properties draw:auto-grow-height="false"/></style:style>
<style:style style:name="grNone" style:family="graphic"><style:graphic-properties draw:fill="none" draw:stroke="none"/></style:style>
</office:automatic-styles>
<office:body><office:text>
<text:p><draw:custom-shape draw:style-name="grInherit" svg:width="4cm" svg:height="3cm"><text:p>inherit</text:p><draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:type="ellipse"/></draw:custom-shape></text:p>
<text:p><draw:custom-shape draw:style-name="grNone" svg:width="4cm" svg:height="3cm"><text:p>none</text:p><draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:type="ellipse"/></draw:custom-shape></text:p>
</office:text></office:body>
</office:document-content>`;
const foreign = zipSync({
mimetype: [strToU8('application/vnd.oasis.opendocument.text'), { level: 0 }],
'content.xml': [strToU8(contentXml), { level: 6 }],
'styles.xml': [strToU8(stylesXml), { level: 6 }],
} as any);
const boxes: N[] = [];
const walk = (n: N): void => { if (n?.type === 'textBox') boxes.push(n); (n?.content || []).forEach(walk); };
walk(importOdt(foreign).content);
check('shape fill/stroke: two ellipses imported', boxes.length === 2, boxes.map((b: N) => b.attrs));
const [inherit, none] = boxes;
check('shape fill/stroke: inherited fill #729FCF', inherit.attrs.fillColor === '#729FCF', inherit.attrs);
check('shape fill/stroke: inherited stroke #3465A4', inherit.attrs.strokeColor === '#3465A4', inherit.attrs);
check('shape fill/stroke: explicit none → null fill', none.attrs.fillColor === null, none.attrs);
check('shape fill/stroke: explicit none → null stroke', none.attrs.strokeColor === null, none.attrs);
});
it('wraps the body in columns when the page layout declares them (Word-style)', () => {
const stylesXml = `<?xml version="1.0" encoding="UTF-8"?>
<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0">