feat: a text box anchors its text top, middle or bottom

Both formats carry a vertical anchor on the box body — ODF
draw:textarea-vertical-align, DOCX wps:bodyPr/@anchor — and both
sides of the I/O hard-coded "top", so an imported box dropped its
centering and a saved one never got it. New textVAlign attr, read
and written in both formats, rendered by flexing the rotor (the
handles are absolute and stay out of that flow), set in the Shape
Format tab beside the vertical-text button.
This commit is contained in:
Steffen Becker committed 2026-09-12 02:35:02 +02:00
1 parent fc5c39322f
commit f33e708b84
13 files changed
+118 -14

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@ const ORDERED_DEFAULTS: Record<string, unknown> = {
fixed: false, key1: '',
joinPrev: false, // pageBreaks.ts's split marker; the export merges the halves
marginLeft: 0, marginRight: 0, marginTop: 0, marginBottom: 0, // a table's, once a command has touched them
alt: '', inFront: false, paddingCm: 0.15, flipV: false, textVertical: false, // the editor's own picture and box defaults
alt: '', inFront: false, paddingCm: 0.15, flipV: false, textVertical: false, textVAlign: 'top', // the editor's own picture and box defaults
wrapFromPage: false,
// index attrs the DOCX TOC field has no switch for come back at their defaults
maxLevel: MAX_HEADING_LEVEL, leader: '.', citationStyle: 'key',
+49
View File
@@ -0,0 +1,49 @@
// Where the text sits in a box taller than it is: ODF's draw:textarea-vertical-align,
// Word's wps:bodyPr/@anchor.
import { describe, it, expect } from 'vitest';
import { unzipSync, strFromU8 } from 'fflate';
import { buildOdt } from '../../src/lib/export/odt';
import { buildDocx } from '../../src/lib/export/docx';
import { importOdt } from '../../src/lib/import/odt';
import { importDocx } from '../../src/lib/import/docx';
type N = any;
const doc = (attrs: N): N => ({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'davor' }] },
{ type: 'paragraph', content: [{
type: 'textBox',
attrs: { width: 200, height: 160, ...attrs },
content: [{ type: 'paragraph', content: [{ type: 'text', text: '53%' }] }],
}] }],
});
const margins = { top: 2, bottom: 2, left: 2, right: 2 };
const boxOf = (r: N): N => {
const find = (n: N): N => n?.type === 'textBox' ? n : (n?.content ?? []).map(find).find(Boolean);
return find((r.content as N));
};
describe('a vertically anchored text box', () => {
it('round-trips through ODF', async () => {
const bytes = await buildOdt(doc({ textVAlign: 'middle' }), margins, 'portrait');
expect(strFromU8(unzipSync(bytes)['content.xml'])).toContain('draw:textarea-vertical-align="middle"');
expect(boxOf(importOdt(bytes))?.attrs.textVAlign).toBe('middle');
});
it('round-trips through DOCX', async () => {
const bytes = await buildDocx(doc({ textVAlign: 'bottom' }), margins, 'portrait');
expect(strFromU8(unzipSync(bytes)['word/document.xml'])).toContain('anchor="b"');
expect(boxOf(importDocx(bytes))?.attrs.textVAlign).toBe('bottom');
});
it('leaves a top-anchored box alone', async () => {
const odt = await buildOdt(doc({}), margins, 'portrait');
expect(strFromU8(unzipSync(odt)['content.xml'])).toContain('draw:textarea-vertical-align="top"');
expect(boxOf(importOdt(odt))?.attrs.textVAlign).toBeUndefined();
const docx = await buildDocx(doc({}), margins, 'portrait');
expect(strFromU8(unzipSync(docx)['word/document.xml'])).toContain('anchor="t"');
expect(boxOf(importDocx(docx))?.attrs.textVAlign).toBeUndefined();
});
});